passiveworkers 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.
council/net/submit.py ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/net/submit.py — ask the networked Council a question
4
+ ============================================================
5
+ Submits a question to the coordinator and polls until the council has answered,
6
+ judged, and merged — then prints the perspectives, scores, the merged answer, and the
7
+ credit movements.
8
+
9
+ Usage:
10
+ PW_COORDINATOR=http://127.0.0.1:8088 PW_TOKEN=… \
11
+ python -m council.net.submit --asker alice "Your question here"
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+ import sys
19
+ import time
20
+
21
+ import requests
22
+
23
+
24
+ def trunc(t: str, n: int = 110) -> str:
25
+ t = " ".join((t or "").split())
26
+ return t if len(t) <= n else t[:n] + "…"
27
+
28
+
29
+ def main() -> int:
30
+ ap = argparse.ArgumentParser()
31
+ ap.add_argument("question")
32
+ ap.add_argument("--asker", default="alice")
33
+ ap.add_argument("--timeout", type=int, default=600)
34
+ args = ap.parse_args()
35
+
36
+ base = os.environ.get("PW_COORDINATOR", "http://127.0.0.1:8088").rstrip("/")
37
+
38
+ # End-users authenticate with their own secret (not the operator token). Get one:
39
+ # reuse PW_USER_SECRET if set, else sign up the handle (with a unique suffix if taken).
40
+ user_secret = os.environ.get("PW_USER_SECRET")
41
+ handle = args.asker
42
+ if not user_secret:
43
+ for attempt in range(3):
44
+ ru = requests.post(f"{base}/users", json={"handle": handle}, timeout=15)
45
+ if ru.status_code == 409: # handle taken → try a fresh one
46
+ handle = f"{args.asker}-{os.urandom(3).hex()}"
47
+ continue
48
+ ru.raise_for_status()
49
+ user_secret = ru.json()["user_secret"]
50
+ break
51
+ if not user_secret:
52
+ print("✗ could not create a user"); return 1
53
+ headers = {"X-User-Secret": user_secret}
54
+
55
+ r = requests.post(f"{base}/jobs", json={"question": args.question},
56
+ headers=headers, timeout=15)
57
+ r.raise_for_status()
58
+ job = r.json()
59
+ if job["status"] == "failed":
60
+ print(f"✗ job failed: {job.get('error')}")
61
+ return 1
62
+ job_id = job["job_id"]
63
+ print(f"submitted job {job_id[:8]}… ({args.asker} asks) → {job.get('assigned', [])} workers")
64
+
65
+ deadline = time.monotonic() + args.timeout
66
+ last = None
67
+ while time.monotonic() < deadline:
68
+ v = requests.get(f"{base}/jobs/{job_id}", timeout=15).json()
69
+ if v["status"] != last:
70
+ print(f" … {v['status']}")
71
+ last = v["status"]
72
+ if v["status"] in ("done", "failed"):
73
+ break
74
+ time.sleep(2)
75
+
76
+ if v["status"] == "failed":
77
+ print(f"✗ job failed: {v.get('error')}")
78
+ return 1
79
+ if v["status"] != "done":
80
+ print("✗ timed out")
81
+ return 1
82
+
83
+ print("\n" + "=" * 78)
84
+ print(f"Q ({v['asker']}): {v['question']}")
85
+ print("=" * 78)
86
+ print(f"\n{'perspective':<34}{'score':>7} one-line")
87
+ print("-" * 78)
88
+ for a in sorted(v["answers"], key=lambda x: -(x["score"] or 0)):
89
+ lbl = f"{a['owner']} [{a['model']}/{a['lens']}/{a['country']}]"
90
+ print(f"{lbl:<34}{(a['score'] or 0):>7.1f} {trunc(a['text'])}")
91
+ print("\nMERGED ANSWER:\n")
92
+ print(v["merged"])
93
+ rec = v.get("receipt") or {}
94
+ if rec:
95
+ payouts = ", ".join(f"{o} +{c:.1f}" for o, c in (rec.get("payouts") or {}).items())
96
+ print(f"\nledger: {rec.get('asker_id')} −{rec.get('total_cost', 0):.1f} | {payouts} | "
97
+ f"judge +{rec.get('judge_fee', 0):.1f}")
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ sys.exit(main())
council/operator.py ADDED
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/operator.py — the operator side of assisted tasks (D21)
4
+ ================================================================
5
+ "Computers doing work for other computers", with a human in the loop. An operator who
6
+ has joined a coordinator can see OPEN assisted offers, give informed consent to one,
7
+ do the work themselves (with their own agentic AI — Claude, Codex — or by hand), and
8
+ deliver the owned result. Our software NEVER automates the operator's computer; the
9
+ human is always the agent and always consents to a bounded brief.
10
+
11
+ pw tasks list open assisted offers you're eligible for
12
+ pw accept <task_id> consent to + claim an offer (prints the full brief)
13
+ pw deliver <task_id> <text> deliver your result (text, or @path to a file)
14
+ pw fingerprint print your signing pubkey + fingerprint to share (operator)
15
+ pw trust add <op> <key> pin an operator's signing key out of band (asker)
16
+ pw trust list | remove <op> show / drop pinned operators (asker)
17
+ pw fetch <job> <dir> download + verify a delivered file (asker)
18
+ pw keygen | rate <job> <0-10> encryption key / rate a deliverable (asker)
19
+
20
+ Requires (operator env): PW_COORDINATOR, PW_TOKEN, PW_OWNER (your handle). Optional:
21
+ PW_NAME, PW_COUNTRY. Identity (node_id + secret) is cached in ~/.passiveworkers/operator.json
22
+ per coordinator so accept→deliver use the same node.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import pathlib
30
+ import platform
31
+ import socket
32
+ import sys
33
+
34
+ import requests
35
+
36
+ STATE = pathlib.Path(os.environ.get("PW_LIBRARY_DIR",
37
+ str(pathlib.Path.home() / ".passiveworkers"))) / "operator.json"
38
+
39
+
40
+ def _profile() -> dict:
41
+ prof = {"os": platform.system(), "machine": platform.machine()}
42
+ try:
43
+ import psutil
44
+ prof["ram_gb"] = round(psutil.virtual_memory().total / 1e9, 1)
45
+ prof["cores"] = psutil.cpu_count(logical=False) or psutil.cpu_count()
46
+ except Exception:
47
+ pass
48
+ try:
49
+ base = os.environ.get("PW_OLLAMA_BASE", "http://localhost:11434")
50
+ r = requests.get(f"{base}/api/tags", timeout=5)
51
+ prof["models"] = sorted(m["name"] for m in r.json().get("models", []))[:40]
52
+ except Exception:
53
+ prof["models"] = []
54
+ return prof
55
+
56
+
57
+ class Operator:
58
+ def __init__(self):
59
+ self.base = os.environ.get("PW_COORDINATOR", "").rstrip("/")
60
+ self.token = os.environ.get("PW_TOKEN", "dev-token")
61
+ if not self.base:
62
+ raise SystemExit("set PW_COORDINATOR (e.g. http://127.0.0.1:8791)")
63
+ self.owner = os.environ.get("PW_OWNER", socket.gethostname())
64
+ self.node_id, self.secret = self._identity()
65
+
66
+ def _headers(self) -> dict:
67
+ h = {"X-PW-Token": self.token, "Content-Type": "application/json"}
68
+ if self.secret:
69
+ h["X-Node-Secret"] = self.secret
70
+ return h
71
+
72
+ def _sign_pub(self) -> str:
73
+ """The operator's persisted Ed25519 verify key (b64), or '' without the crypto extra."""
74
+ try:
75
+ from council import crypto as C
76
+ if not C.available():
77
+ return ""
78
+ kp = C.load_or_create(STATE.parent / "operator_keys.json", "sign")
79
+ self._sign_priv = kp["priv"]
80
+ return kp["pub"]
81
+ except Exception:
82
+ return ""
83
+
84
+ def _sign(self, data: bytes) -> tuple[str, str]:
85
+ """(signature_b64, signer_pub_b64) over data, or ('','') without the crypto extra."""
86
+ pub = self._sign_pub()
87
+ if not pub:
88
+ return "", ""
89
+ try:
90
+ from council import crypto as C
91
+ return C.sign(self._sign_priv, data), pub
92
+ except Exception:
93
+ return "", ""
94
+
95
+ def _identity(self) -> tuple[str, str]:
96
+ """Reuse a cached node identity for this coordinator, else register one."""
97
+ if STATE.exists():
98
+ try:
99
+ cache = json.loads(STATE.read_text()).get(self.base)
100
+ if cache:
101
+ return cache["node_id"], cache["secret"]
102
+ except Exception:
103
+ pass
104
+ prof = _profile()
105
+ prof["sign_pub"] = self._sign_pub() # publish the operator's verify key (D23)
106
+ body = {"name": os.environ.get("PW_NAME", socket.gethostname()),
107
+ "country": os.environ.get("PW_COUNTRY", "local"), "owner": self.owner,
108
+ "answer_model": "", "lens": "operator", "can_judge": False,
109
+ "judge_model": "", "machine_id": os.environ.get("PW_MACHINE_ID", socket.gethostname()),
110
+ "profile": prof}
111
+ r = requests.post(f"{self.base}/nodes/register", json=body,
112
+ headers={"X-PW-Token": self.token}, timeout=15)
113
+ r.raise_for_status()
114
+ d = r.json()
115
+ STATE.parent.mkdir(parents=True, exist_ok=True)
116
+ all_state = {}
117
+ if STATE.exists():
118
+ try:
119
+ all_state = json.loads(STATE.read_text())
120
+ except Exception:
121
+ all_state = {}
122
+ all_state[self.base] = {"node_id": d["node_id"], "secret": d["node_secret"]}
123
+ STATE.write_text(json.dumps(all_state))
124
+ try:
125
+ os.chmod(STATE, 0o600) # node_secret is a bearer credential — owner-only
126
+ except Exception:
127
+ pass
128
+ return d["node_id"], d["node_secret"]
129
+
130
+ def tasks(self) -> int:
131
+ offers = requests.get(f"{self.base}/tasks/offers", headers=self._headers(),
132
+ timeout=15).json().get("offers", [])
133
+ if not offers:
134
+ print("No open assisted offers you're eligible for right now.")
135
+ return 0
136
+ for o in offers:
137
+ print(f"\n● {o['task_id']} (reward {o['price']} cr · open {o['age_s']:.0f}s)")
138
+ print(f" brief: {o['brief']}")
139
+ if o.get("requires"):
140
+ print(f" needs: {o['requires']}")
141
+ print("\nAccept one with: pw accept <task_id>")
142
+ return 0
143
+
144
+ def accept(self, task_id: str) -> int:
145
+ r = requests.post(f"{self.base}/tasks/{task_id}/accept", headers=self._headers(), timeout=15)
146
+ if not r.ok:
147
+ print(f"✗ {r.json().get('detail', r.text)}"); return 1
148
+ d = r.json()
149
+ print(f"✓ accepted {task_id}\n\nBRIEF:\n{d['brief']}\n")
150
+ if d.get("context"):
151
+ print(f"CONTEXT:\n{d['context']}\n")
152
+ print("Do the work (your own AI or by hand), then:\n"
153
+ f" pw deliver {task_id} \"<your result>\" (or @path/to/file)")
154
+ return 0
155
+
156
+ def _send_deliver(self, task_id: str, deliverable: str) -> "requests.Response":
157
+ payload = deliverable[:200_000] # the exact bytes that get stored/verified
158
+ sig, signer = self._sign(payload.encode()) # sign EXACTLY what is delivered (D23)
159
+ return requests.post(f"{self.base}/tasks/{task_id}/deliver", headers=self._headers(),
160
+ data=json.dumps({"deliverable": payload,
161
+ "signature": sig, "signer_pub": signer}), timeout=30)
162
+
163
+ def deliver(self, task_id: str, deliverable: str, job_id: str = "") -> int:
164
+ # @path delivers a real FILE: chunk → upload content-addressed blobs → deliver a signed
165
+ # manifest the asker fetches+verifies+reassembles (D22). Plain text stays inline. If the
166
+ # asker published an encryption key (encrypt_to), each chunk is sealed to it (D23).
167
+ if deliverable.startswith("@"):
168
+ from council import artifacts as A
169
+ path = deliverable[1:]
170
+ if not job_id:
171
+ print("✗ file delivery needs the job id: pw deliver <task_id> @file <job_id>")
172
+ return 2
173
+ view = requests.get(f"{self.base}/jobs/{job_id}", timeout=15).json()
174
+ encrypt_to = view.get("encrypt_to") or ""
175
+ if encrypt_to:
176
+ from council import crypto as C
177
+ if not C.available():
178
+ print("✗ this job requires encryption — install the crypto extra: pip install 'passiveworkers[crypto]'")
179
+ return 2
180
+ manifest, blobs = A.chunk_file_encrypted(path, lambda b: C.seal(encrypt_to, b))
181
+ tag = " (encrypted)"
182
+ else:
183
+ manifest, blobs = A.chunk_file(path)
184
+ tag = ""
185
+ bh = {"X-PW-Token": self.token, "Content-Type": "application/octet-stream"}
186
+ if self.secret:
187
+ bh["X-Node-Secret"] = self.secret
188
+ for h, buf in blobs.items():
189
+ rb = requests.post(f"{self.base}/jobs/{job_id}/blobs/{h}", headers=bh,
190
+ data=buf, timeout=60)
191
+ if not rb.ok:
192
+ print(f"✗ upload failed: {rb.json().get('detail', rb.text)}"); return 1
193
+ r = self._send_deliver(task_id, A.wrap_artifact(manifest))
194
+ if not r.ok:
195
+ print(f"✗ {r.json().get('detail', r.text)}"); return 1
196
+ print(f"✓ delivered file '{manifest['name']}' ({len(blobs)} chunks){tag} — you've been paid.")
197
+ return 0
198
+ r = self._send_deliver(task_id, deliverable)
199
+ if not r.ok:
200
+ print(f"✗ {r.json().get('detail', r.text)}"); return 1
201
+ print(f"✓ delivered — you've been paid. job {r.json().get('job_id', '')[:8]}")
202
+ return 0
203
+
204
+
205
+ def _verify_delivery_signature(view: dict, merged: str) -> tuple[bool, int]:
206
+ """Decide whether a delivery's signature clears out-of-band trust (D25). Pure except for the
207
+ TOFU pin side effect (which it surfaces, never swallows). Returns (ok, exit_code): ok=False
208
+ means the caller must abort with exit_code.
209
+
210
+ The guarantee for a PINNED operator holds even against a fully hostile coordinator:
211
+ • it can't simply omit the signature (a pinned operator MUST sign → refused),
212
+ • it can't blank the operator handle to dodge the pin (signed-but-unidentified → refused),
213
+ • it can't present a different key (classify → MISMATCH → refused),
214
+ • it can't forge a signature under the pinned key (crypto.verify fails → refused),
215
+ • it can't make us accept unverified by hiding the crypto extra (→ refused, not downgraded).
216
+ """
217
+ from council import trust
218
+ sig, signer = view.get("signature"), view.get("signer_pub")
219
+ operator = (view.get("operator") or "").strip()
220
+ # A signed delivery must name its operator, or there's no out-of-band anchor to trust.
221
+ if sig and signer and not operator:
222
+ print("✗ signed delivery has no operator handle to anchor trust — refusing")
223
+ return False, 1
224
+ pinned = trust.get(operator) if operator else None
225
+ # A pinned operator always signs; an unsigned delivery from them is a downgrade attack.
226
+ if pinned and not (sig and signer):
227
+ print(f"✗ operator '{operator}' is pinned but this delivery is unsigned — refusing")
228
+ return False, 1
229
+ if not (sig and signer):
230
+ print("⚠ delivery is unsigned and the operator is unpinned — integrity is NOT "
231
+ "cryptographically verified (ask the operator to deliver with the crypto extra, "
232
+ "then pin them with `pw trust add`)")
233
+ return True, 0
234
+ status, existing = trust.classify(operator, signer)
235
+ if status == trust.MISMATCH:
236
+ print(f"✗ operator '{operator}' presented key {trust.fingerprint(signer)} but the pinned "
237
+ f"key is {existing['fp']} — refusing (key rotation, another machine, or tampering).\n"
238
+ f" If you trust the new key, verify it out of band then: pw trust add {operator} {signer}")
239
+ return False, 1
240
+ from council import crypto as C
241
+ if not C.available():
242
+ print("✗ signature present but the crypto extra isn't installed — cannot verify, refusing "
243
+ "to accept unverified (pip install 'passiveworkers[crypto]')")
244
+ return False, 2
245
+ verify_key = existing["pub"] if existing else signer
246
+ if not C.verify(verify_key, merged.encode(), sig):
247
+ print("✗ signature INVALID — deliverable may be tampered or signed by another key")
248
+ return False, 1
249
+ if status == trust.PINNED_MATCH:
250
+ print(f"signature: ✓ valid · operator '{operator}' matches pinned key {existing['fp']}")
251
+ else: # UNPINNED → TOFU-pin a VALID key (never a bad one)
252
+ try:
253
+ trust.pin(operator, signer, source="tofu")
254
+ print(f"signature: ✓ valid · first contact — pinned operator '{operator}' as "
255
+ f"{trust.fingerprint(signer)} (confirm out of band: ask them for `pw fingerprint`)")
256
+ except Exception as e: # surface a persistence failure, don't hide it
257
+ print(f"signature: ✓ valid, but could NOT save the trust pin ({e}) — future deliveries "
258
+ f"from '{operator}' won't be auto-verified until you run: pw trust add {operator} {signer}")
259
+ return True, 0
260
+
261
+
262
+ def fetch(job_id: str, out_dir: str) -> int:
263
+ """Asker side: download a delivered FILE artifact, verify every chunk, reassemble.
264
+ Needs PW_COORDINATOR + PW_USER_SECRET (the asker's secret)."""
265
+ from council import artifacts as A
266
+ base = os.environ.get("PW_COORDINATOR", "").rstrip("/")
267
+ sec = os.environ.get("PW_USER_SECRET", "")
268
+ if not base or not sec:
269
+ print("✗ set PW_COORDINATOR and PW_USER_SECRET (the asker's secret)"); return 2
270
+ uh = {"X-User-Secret": sec}
271
+ view = requests.get(f"{base}/jobs/{job_id}", timeout=15).json()
272
+ merged = view.get("merged") or ""
273
+ ok, code = _verify_delivery_signature(view, merged)
274
+ if not ok:
275
+ return code
276
+ # downgrade guard: if WE required encryption (encrypt_to set), refuse a plaintext deliverable
277
+ if view.get("encrypt_to"):
278
+ m = A.read_artifact(merged)
279
+ if not (m and m.get("encrypted")):
280
+ print("✗ this job required encryption but the deliverable is not encrypted — rejecting")
281
+ return 1
282
+ manifest = A.read_artifact(merged)
283
+ if not manifest:
284
+ print("This job's deliverable is text, not a file:\n")
285
+ print(merged[:2000]); return 0
286
+ decrypt = None
287
+ if manifest.get("encrypted"):
288
+ from council import crypto as C
289
+ if not C.available():
290
+ print("✗ encrypted deliverable — install: pip install 'passiveworkers[crypto]'"); return 2
291
+ kp = C.load_or_create(_asker_keys_path(), "box")
292
+ decrypt = lambda ct: C.unseal(kp["priv"], ct)
293
+ def _chunk(h):
294
+ r = requests.get(f"{base}/jobs/{job_id}/blob/{h}", headers=uh, timeout=60)
295
+ return r.content if r.ok else None
296
+ dest = A.reassemble(manifest, _chunk, out_dir, decrypt=decrypt)
297
+ print(f"✓ verified + reassembled → {dest}")
298
+ return 0
299
+
300
+
301
+ def _asker_keys_path():
302
+ return pathlib.Path(os.environ.get("PW_LIBRARY_DIR",
303
+ str(pathlib.Path.home() / ".passiveworkers"))) / "asker_keys.json"
304
+
305
+
306
+ def rate(job_id: str, score: str) -> int:
307
+ """Asker side: rate a completed assisted job (0-10) → the operator's reputation.
308
+ Needs PW_COORDINATOR + PW_USER_SECRET (the asker's secret)."""
309
+ base = os.environ.get("PW_COORDINATOR", "").rstrip("/")
310
+ sec = os.environ.get("PW_USER_SECRET", "")
311
+ if not base or not sec:
312
+ print("✗ set PW_COORDINATOR and PW_USER_SECRET (the asker's secret)"); return 2
313
+ try:
314
+ s = float(score)
315
+ except ValueError:
316
+ print("✗ score must be a number 0-10"); return 2
317
+ r = requests.post(f"{base}/jobs/{job_id}/rate", headers={"X-User-Secret": sec},
318
+ data=json.dumps({"score": s}), timeout=15)
319
+ if not r.ok:
320
+ print(f"✗ {r.json().get('detail', r.text)}"); return 1
321
+ print(f"✓ rated — operator reputation now {r.json().get('operator_reputation')}")
322
+ return 0
323
+
324
+
325
+ def keygen() -> int:
326
+ """Asker: create/reveal your encryption PUBLIC key. Include it as `encrypt_to` when posting
327
+ an assisted job so operators encrypt the deliverable so only you can open it."""
328
+ from council import crypto as C
329
+ if not C.available():
330
+ print("install the crypto extra first: pip install 'passiveworkers[crypto]'"); return 2
331
+ kp = C.load_or_create(_asker_keys_path(), "box")
332
+ print("Your encryption public key (pass as encrypt_to when posting a job):\n")
333
+ print(kp["pub"])
334
+ return 0
335
+
336
+
337
+ def fingerprint() -> int:
338
+ """Operator: print your SIGNING public key + its fingerprint so an asker can pin you out of
339
+ band (the trust anchor a hostile coordinator can't forge, D25)."""
340
+ from council import crypto as C
341
+ from council import trust
342
+ if not C.available():
343
+ print("install the crypto extra first: pip install 'passiveworkers[crypto]'"); return 2
344
+ kp = C.load_or_create(STATE.parent / "operator_keys.json", "sign")
345
+ print("Your signing public key (share with askers so they can `pw trust add <you> <key>`):\n")
346
+ print(kp["pub"])
347
+ print(f"\nFingerprint (read this to them over a trusted channel): {trust.fingerprint(kp['pub'])}")
348
+ return 0
349
+
350
+
351
+ def trust_cmd(args: list) -> int:
352
+ """Asker: manage pinned operator signing keys (out-of-band trust, D25).
353
+ pw trust add <operator> <pubkey> pin an operator's signing key
354
+ pw trust list show pinned operators + fingerprints
355
+ pw trust remove <operator> unpin
356
+ """
357
+ from council import trust
358
+ sub = args[0] if args else ""
359
+ if sub == "add" and len(args) >= 3:
360
+ rec = trust.pin(args[1], args[2], source="manual")
361
+ print(f"✓ pinned operator '{args[1]}' → {rec['fp']}\n"
362
+ f" Confirm this fingerprint matches what the operator told you (pw fingerprint).")
363
+ return 0
364
+ if sub == "list":
365
+ pins = trust.list_pins()
366
+ if not pins:
367
+ print("No pinned operators yet. They're pinned on first signed delivery (TOFU), or "
368
+ "explicitly with: pw trust add <operator> <pubkey>")
369
+ return 0
370
+ for op, rec in sorted(pins.items()):
371
+ print(f" {op:<20} {rec['fp']} ({rec.get('source', '?')})")
372
+ return 0
373
+ if sub == "remove" and len(args) >= 2:
374
+ print("✓ unpinned" if trust.remove(args[1]) else "✗ no such pinned operator")
375
+ return 0
376
+ print("usage: pw trust add <operator> <pubkey> | pw trust list | pw trust remove <operator>")
377
+ return 2
378
+
379
+
380
+ def main() -> int:
381
+ args = sys.argv[1:]
382
+ if not args:
383
+ print("usage: pw tasks | accept <id> | deliver <id> <text|@file <job>> | fetch <job> <dir>\n"
384
+ " keygen | rate <job> <0-10> | fingerprint | trust add|list|remove")
385
+ return 2
386
+ if args[0] == "fetch" and len(args) >= 3:
387
+ return fetch(args[1], args[2])
388
+ if args[0] == "keygen":
389
+ return keygen()
390
+ if args[0] == "fingerprint":
391
+ return fingerprint()
392
+ if args[0] == "trust":
393
+ return trust_cmd(args[1:])
394
+ if args[0] == "rate" and len(args) >= 3:
395
+ return rate(args[1], args[2])
396
+ op = Operator()
397
+ cmd, rest = args[0], args[1:]
398
+ if cmd == "tasks":
399
+ return op.tasks()
400
+ if cmd == "accept" and rest:
401
+ return op.accept(rest[0])
402
+ if cmd == "deliver" and len(rest) >= 2:
403
+ # file form: pw deliver <task_id> @path <job_id> · text form: pw deliver <task_id> <text…>
404
+ if rest[1].startswith("@"):
405
+ return op.deliver(rest[0], rest[1], rest[2] if len(rest) > 2 else "")
406
+ return op.deliver(rest[0], " ".join(rest[1:]))
407
+ print("usage: pw tasks | pw accept <id> | pw deliver <id> <text | @file <job_id>>")
408
+ return 2
409
+
410
+
411
+ if __name__ == "__main__":
412
+ sys.exit(main())