docketry 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.
docketry/cli.py ADDED
@@ -0,0 +1,634 @@
1
+ """Docketry CLI: init, poll, queue, approve, advance, status."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os as _os
6
+
7
+ def _color(code: str, s: str) -> str:
8
+ if not sys.stdout.isatty() or _os.environ.get("NO_COLOR"):
9
+ return s
10
+ return f"\033[{code}m{s}\033[0m"
11
+
12
+ def _sev(marker: str) -> str:
13
+ return {"FAIL": _color("31;1", "FAIL"), "ERROR": _color("31;1", "ERROR"),
14
+ "warn": _color("33", "warn"), "WARN": _color("33", "WARN"),
15
+ " ok": _color("32", " ok")}.get(marker, marker)
16
+ import getpass
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from . import __version__
22
+ from . import store as st
23
+ from .config import MANIFEST_NAME, load_home, write_config
24
+ from .envelope import parse_message
25
+ from .mailbox import IntakeMailbox
26
+ from .manifest import DEFAULT_MANIFEST, load_manifest
27
+ from . import notices as notices_mod
28
+ from .pipeline import GateRefusal, Runner
29
+ from .store import Store
30
+
31
+
32
+ def _open(home: str):
33
+ cfg = load_home(home)
34
+ if not cfg.manifest_path.exists():
35
+ sys.exit(f"no {MANIFEST_NAME} in {home} — run: docketry init")
36
+ pipeline = load_manifest(cfg.manifest_path)
37
+ return cfg, pipeline, Store(cfg.store_path)
38
+
39
+
40
+ def cmd_init(args) -> None:
41
+ home = Path(args.home)
42
+ host = args.host or input("IMAP host of the intake mailbox (e.g. imap.gmail.com): ").strip()
43
+ user = args.user or input("Intake mailbox address (e.g. intake@yourfirm.com): ").strip()
44
+ folder = args.folder or "INBOX"
45
+ password = None
46
+ if args.store_password:
47
+ password = getpass.getpass("Mailbox password/app password (stored 0600): ")
48
+ write_config(home, host=host, user=user, folder=folder, password=password)
49
+ manifest = home / MANIFEST_NAME
50
+ if not manifest.exists():
51
+ manifest.write_text(DEFAULT_MANIFEST)
52
+ load_manifest(manifest) # refuse to init half-configured
53
+ print(f"initialized {home}")
54
+ print(f" config: {home / 'config.toml'}")
55
+ print(f" manifest: {manifest}")
56
+ if not password:
57
+ print(" password: set DOCKETRY_IMAP_PASSWORD in the environment")
58
+ print("next: point a forwarding rule at the intake mailbox, then run: docketry poll")
59
+
60
+
61
+ def cmd_poll(args) -> None:
62
+ cfg, pipeline, store = _open(args.home)
63
+ if cfg.mailbox is None:
64
+ sys.exit("no [mailbox] configured — run: docketry init")
65
+ if not cfg.mailbox.password:
66
+ sys.exit("no mailbox password (set DOCKETRY_IMAP_PASSWORD)")
67
+ runner = Runner(pipeline, store)
68
+ first_stage = pipeline.stages[0]
69
+ adapters_file = cfg.home / "adapters.toml"
70
+ adapter_stack = notices_mod.stack(adapters_file if adapters_file.exists() else None)
71
+ ingested = held = parsed = 0
72
+ with IntakeMailbox(cfg.mailbox) as mb:
73
+ uidvalidity, last_uid = store.imap_cursor(mb.label)
74
+ current_validity = mb.uidvalidity()
75
+ if uidvalidity is not None and current_validity != uidvalidity:
76
+ print("mailbox UIDVALIDITY changed; resweeping from the start (dedupe is by content hash)")
77
+ last_uid = 0
78
+ max_uid = last_uid
79
+ for uid, raw in mb.new_messages(last_uid):
80
+ env = parse_message(raw, source=mb.label, fetched_at=st.utcnow())
81
+ msg_id = store.ingest(env, first_stage=first_stage)
82
+ max_uid = max(max_uid, uid)
83
+ if msg_id is None:
84
+ continue
85
+ ingested += 1
86
+ from .classify import classify as _classify
87
+ for att in store.attachments_for(msg_id):
88
+ label, tier = _classify(att["filename"])
89
+ if tier != "low":
90
+ store.stage_classification(att["id"], label, tier)
91
+ result = notices_mod.parse(env, adapter_stack)
92
+ if result is not None:
93
+ store.add_notice(msg_id, result.adapter, result.notice_type,
94
+ result.fields, result.missing)
95
+ parsed += 1
96
+ status = runner.enter(msg_id)
97
+ while status == st.OK:
98
+ status = runner.advance(msg_id)
99
+ if status in (st.PENDING_REVIEW, st.BLOCKED):
100
+ held += 1
101
+ store.set_imap_cursor(mb.label, current_validity, max_uid)
102
+ print(f"ingested {ingested} new message(s); {parsed} parsed as court notices; {held} held for review")
103
+ if held:
104
+ print("run: docketry queue")
105
+
106
+
107
+ def _print_row(store: Store, row) -> None:
108
+ env = json.loads(row["envelope_json"])
109
+ print(f"[{row['id']}] {row['status']} @ {row['stage']} {env['subject']!r} from {env['from_addr']}")
110
+ for f in store.findings_for(row["id"]):
111
+ if f["severity"] != "info":
112
+ print(f" {f['severity']}: ({f['gate_id']}) {f['summary']}")
113
+
114
+
115
+ def cmd_queue(args) -> None:
116
+ _, _, store = _open(args.home)
117
+ rows = store.list_by_status(st.PENDING_REVIEW) + store.list_by_status(st.BLOCKED)
118
+ if not rows:
119
+ print("queue empty")
120
+ return
121
+ for row in rows:
122
+ _print_row(store, row)
123
+
124
+
125
+ def cmd_approve(args) -> None:
126
+ cfg, pipeline, store = _open(args.home)
127
+ row = store.get_message(args.message)
128
+ if row is None:
129
+ sys.exit(f"no message {args.message}")
130
+ stage_bindings = {b.gate.id: b for b in pipeline.bindings_for(row["stage"])}
131
+ binding = stage_bindings.get(args.gate)
132
+ if binding is None:
133
+ sys.exit(f"gate '{args.gate}' is not bound at stage '{row['stage']}'"
134
+ f" (bound here: {', '.join(sorted(stage_bindings)) or 'none'})")
135
+ if args.role != binding.authority:
136
+ sys.exit(f"gate '{args.gate}' at stage '{row['stage']}' requires role"
137
+ f" '{binding.authority}', not '{args.role}' — approval not recorded")
138
+ store.add_approval(
139
+ args.message, row["stage"], args.gate,
140
+ approved_by=args.by, role=args.role, note=args.note or "",
141
+ )
142
+ runner = Runner(pipeline, store)
143
+ try:
144
+ status = runner.advance(args.message)
145
+ print(f"approved; message {args.message} -> {status}")
146
+ except GateRefusal as e:
147
+ print(f"approval recorded, but: {e}")
148
+
149
+
150
+ def cmd_advance(args) -> None:
151
+ _, pipeline, store = _open(args.home)
152
+ runner = Runner(pipeline, store)
153
+ try:
154
+ status = runner.advance(args.message)
155
+ print(f"message {args.message} -> {status}")
156
+ except GateRefusal as e:
157
+ sys.exit(str(e))
158
+
159
+
160
+ def cmd_notices(args) -> None:
161
+ _, _, store = _open(args.home)
162
+ rows = store.list_notices(args.type)
163
+ if not rows:
164
+ print("no notices parsed yet")
165
+ return
166
+ for r in rows:
167
+ fields = json.loads(r["fields_json"])
168
+ missing = json.loads(r["missing_json"])
169
+ line = f"[msg {r['message_id']}] {r['notice_type']} via {r['adapter']}: " + ", ".join(
170
+ f"{k}={v}" for k, v in fields.items()
171
+ )
172
+ if missing:
173
+ line += f" MISSING: {', '.join(missing)}"
174
+ print(line)
175
+
176
+
177
+ def cmd_verify_draft(args) -> None:
178
+ from .cite import CiteError, verify, extract_citations
179
+ from .extract import ExtractionError, extract_path
180
+
181
+ try:
182
+ text = extract_path(args.file).full_text
183
+ except ExtractionError as e:
184
+ sys.exit(str(e))
185
+ try:
186
+ if args.offline:
187
+ raise CiteError("offline requested")
188
+ from .cite_client import CourtListenerClient
189
+ client = CourtListenerClient(token=args.token)
190
+ report = verify(text, client)
191
+ client.close()
192
+ except CiteError as e:
193
+ if not args.offline:
194
+ print(f"network verification unavailable ({e}); extraction-only mode")
195
+ try:
196
+ from .cite import citation_inventory
197
+ cites, n_short = citation_inventory(text)
198
+ except CiteError as e2:
199
+ sys.exit(str(e2))
200
+ print(f"{len(cites)} full citation(s) found — NOT verified:")
201
+ for c in cites:
202
+ name = f"{c.plaintiff} v. {c.defendant}".strip(" v.")
203
+ print(f" {name}, {c.text}" + (f" (pin p. {c.pin_page})" if c.pin_page else ""))
204
+ if n_short:
205
+ print(f"plus {n_short} short-form citation(s)"
206
+ + (" — WITH NO FULL CITATION IN THIS DOCUMENT; unverifiable as written"
207
+ if not cites else " riding on the fulls above"))
208
+ sys.exit(2)
209
+ fails = [f for f in report.findings if f.severity == "fail"]
210
+ warns = [f for f in report.findings if f.severity == "warn"]
211
+ for f in report.findings:
212
+ marker = _sev({"fail": "FAIL", "warn": "warn", "info": " ok"}[f.severity])
213
+ print(f"{marker} [{f.check}] {f.summary}")
214
+ print(f"\n{len(report.citations)} citation(s): {len(fails)} failed,"
215
+ f" {len(warns)} warning(s)")
216
+ if fails:
217
+ sys.exit(1)
218
+
219
+
220
+ def cmd_lint(args) -> None:
221
+ from .extract import ExtractionError, extract_path
222
+ from .lint import RulepackError, lint, load_rulepack
223
+
224
+ try:
225
+ text = extract_path(args.file).full_text
226
+ except ExtractionError as e:
227
+ sys.exit(str(e))
228
+ rulepack = None
229
+ if args.rules:
230
+ try:
231
+ rulepack = load_rulepack(args.rules)
232
+ except RulepackError as e:
233
+ sys.exit(f"rulepack refused: {e}")
234
+ findings = lint(text, rulepack)
235
+ errors = [f for f in findings if f.severity == "error"]
236
+ for f in findings:
237
+ loc = f"line {f.line}" if f.line else "document"
238
+ print(f"{_sev(f.severity.upper()):5} {loc:>10} [{f.rule}] {f.message}")
239
+ if f.excerpt:
240
+ print(f" > {f.excerpt}")
241
+ print(f"\n{len(findings)} finding(s), {len(errors)} error(s)")
242
+ if errors:
243
+ sys.exit(1)
244
+
245
+
246
+ def cmd_classify(args) -> None:
247
+ from .classify import classify
248
+ from .extract import ExtractionError, extract_path
249
+
250
+ text = ""
251
+ try:
252
+ text = extract_path(args.file).full_text
253
+ except ExtractionError:
254
+ pass # title-only classification still works
255
+ label, tier = classify(Path(args.file).stem, text)
256
+ print(f"{label} ({tier})")
257
+
258
+
259
+ def cmd_class_queue(args) -> None:
260
+ _, _, store = _open(args.home)
261
+ rows = store.open_classifications()
262
+ if not rows:
263
+ print("no staged classifications")
264
+ return
265
+ for r in rows:
266
+ current = f" (current: {r['doc_type']})" if r["doc_type"] else ""
267
+ print(f"[{r['id']}] {r['filename']} -> {r['label']} ({r['tier']}){current}")
268
+
269
+
270
+ def cmd_class_apply(args) -> None:
271
+ _, _, store = _open(args.home)
272
+ outcome = store.apply_classification(args.id, by=args.by, role=args.role)
273
+ print(outcome)
274
+
275
+
276
+ def cmd_ui(args) -> None:
277
+ from .webui import make_server
278
+ cfg, pipeline, store = _open(args.home)
279
+ store.close()
280
+ server = make_server(cfg.store_path, pipeline, port=args.port)
281
+ print(f"Docketry review UI: http://127.0.0.1:{args.port}/ (Ctrl-C to stop)")
282
+ try:
283
+ server.serve_forever()
284
+ except KeyboardInterrupt:
285
+ print("\nstopped")
286
+
287
+
288
+ def cmd_watch(args) -> None:
289
+ import time
290
+ print(f"sweeping every {args.every}s (Ctrl-C to stop)")
291
+ while True:
292
+ try:
293
+ cmd_poll(args)
294
+ except SystemExit as e:
295
+ raise
296
+ except Exception as e:
297
+ print(f"sweep failed: {e} — retrying in {args.every}s")
298
+ try:
299
+ time.sleep(args.every)
300
+ except KeyboardInterrupt:
301
+ print("\nstopped")
302
+ return
303
+
304
+
305
+ def cmd_doctor(args) -> None:
306
+ import shutil
307
+ from .config import load_home
308
+ from .manifest import ManifestError, load_manifest as _lm
309
+
310
+ ok = True
311
+
312
+ def report(level, msg):
313
+ nonlocal ok
314
+ if level == "FAIL":
315
+ ok = False
316
+ print(f"{_sev(level if level != 'PASS' else ' ok'):5} {msg}")
317
+
318
+ home = Path(args.home)
319
+ if not home.exists():
320
+ report("FAIL", f"home {home} does not exist — run: docketry init")
321
+ sys.exit(1)
322
+ report("PASS", f"home: {home}")
323
+ cfg = load_home(home)
324
+ if cfg.mailbox is None:
325
+ report("WARN", "no [mailbox] configured (init with --host/--user to poll)")
326
+ else:
327
+ report("PASS", f"mailbox: {cfg.mailbox.user} @ {cfg.mailbox.host} ({cfg.mailbox.folder})")
328
+ if not cfg.mailbox.password:
329
+ report("WARN", "no mailbox password: set DOCKETRY_IMAP_PASSWORD")
330
+ if cfg.manifest_path.exists():
331
+ try:
332
+ pipeline = _lm(cfg.manifest_path)
333
+ report("PASS", f"manifest: stages {pipeline.stages}, "
334
+ f"{len(pipeline.bindings)} gate binding(s)")
335
+ except ManifestError as e:
336
+ report("FAIL", f"manifest refused: {e}")
337
+ else:
338
+ report("FAIL", f"no {cfg.manifest_path.name} — run: docketry init")
339
+ adapters = home / "adapters.toml"
340
+ if adapters.exists():
341
+ from .notices import AdapterError, load_adapters_file
342
+ try:
343
+ n = len(load_adapters_file(adapters))
344
+ report("PASS", f"firm adapters: {n} loaded")
345
+ except AdapterError as e:
346
+ report("FAIL", f"adapters.toml refused: {e}")
347
+ else:
348
+ report("PASS", "no firm adapters.toml (built-ins only)")
349
+ for mod, extra, what in (("pypdf", "pdf", "PDF extraction"),
350
+ ("docx", "docx", "DOCX extraction"),
351
+ ("eyecite", "cite", "citation extraction"),
352
+ ("httpx", "cite", "citation verification")):
353
+ try:
354
+ __import__(mod)
355
+ report("PASS", f"{what} available")
356
+ except ImportError:
357
+ report("WARN", f"{what} missing — pip install 'docketry[{extra}]'")
358
+ for binary, pkg in (("tesseract", "tesseract-ocr"), ("pdftoppm", "poppler-utils")):
359
+ if shutil.which(binary):
360
+ report("PASS", f"{binary} present (OCR possible)")
361
+ else:
362
+ report("WARN", f"{binary} missing — scanned PDFs need {pkg}")
363
+ if _os.environ.get("COURTLISTENER_TOKEN"):
364
+ report("PASS", "COURTLISTENER_TOKEN set (live citation verification)")
365
+ else:
366
+ report("WARN", "COURTLISTENER_TOKEN not set — verify-draft runs extraction-only")
367
+ sys.exit(0 if ok else 1)
368
+
369
+
370
+ def cmd_stats(args) -> None:
371
+ _, _, store = _open(args.home)
372
+ s = store.stats(days=args.days)
373
+ if args.json:
374
+ print(json.dumps(s, indent=2))
375
+ return
376
+ print(f"last {s['days']} day(s): {s['ingested']} message(s) ingested")
377
+ for k, v in sorted(s["by_status"].items()):
378
+ print(f" {k:15} {v}")
379
+ if s["holds_by_gate"]:
380
+ print("holds by gate:")
381
+ for g, n in s["holds_by_gate"].items():
382
+ print(f" {g:20} {n}")
383
+ if s["notices_by_type"]:
384
+ print("court notices:")
385
+ for t, n in sorted(s["notices_by_type"].items()):
386
+ print(f" {t:20} {n}")
387
+ if s["template_drift_messages"]:
388
+ print(f"TEMPLATE DRIFT: {s['template_drift_messages']} message(s) — a court"
389
+ " system may have changed its email format")
390
+ if s["avg_hours_to_release"] is not None:
391
+ print(f"avg hours held before release: {s['avg_hours_to_release']}")
392
+ if s["approvals_by_role"]:
393
+ print("approvals recorded (audit): "
394
+ + ", ".join(f"{r}={n}" for r, n in sorted(s["approvals_by_role"].items())))
395
+ if s["classifications"]:
396
+ print("doc-type proposals: "
397
+ + ", ".join(f"{k}={n}" for k, n in sorted(s["classifications"].items())))
398
+
399
+
400
+ def cmd_digest(args) -> None:
401
+ """Prints a paste-anywhere summary. Docketry never sends anything."""
402
+ _, _, store = _open(args.home)
403
+ s = store.stats(days=1)
404
+ held = store.list_by_status("pending_review") + store.list_by_status("blocked")
405
+ lines = [f"Docketry intake digest — {len(held)} awaiting review,"
406
+ f" {s['ingested']} ingested in the last day"]
407
+ for row in held[:15]:
408
+ env = json.loads(row["envelope_json"])
409
+ gates = sorted({f["gate_id"] for f in store.findings_for(row["id"])
410
+ if f["severity"] == "fail"})
411
+ lines.append(f" [{row['id']}] {env['subject']!r} from {env['from_addr']}"
412
+ f" — held by {', '.join(gates) or 'unknown'}")
413
+ if s["template_drift_messages"]:
414
+ lines.append(f" NOTE: {s['template_drift_messages']} template-drift event(s)"
415
+ " — check portal formats")
416
+ print("\n".join(lines))
417
+
418
+
419
+ def cmd_demo(args) -> None:
420
+ """Seed a disposable home with sample traffic and open the dashboard."""
421
+ import tempfile
422
+ import webbrowser
423
+ from email.message import EmailMessage
424
+
425
+ from . import notices as nmod
426
+ from .classify import classify as _classify
427
+ from .envelope import parse_message
428
+ from .manifest import load_manifest
429
+ from .pipeline import Runner
430
+ from .store import Store
431
+ from .webui import make_server
432
+
433
+ home = Path(tempfile.mkdtemp(prefix="docketry-demo-"))
434
+ (home / "guardrails.toml").write_text(DEMO_MANIFEST)
435
+ pipeline = load_manifest(home / "guardrails.toml")
436
+ store = Store(home / "store")
437
+ runner = Runner(pipeline, store)
438
+ adapter_stack = nmod.stack()
439
+
440
+ def mail(from_addr, subject, body, attach=None):
441
+ m = EmailMessage()
442
+ m["From"] = from_addr
443
+ m["To"] = "intake@demofirm.example"
444
+ m["Subject"] = subject
445
+ m.set_content(body)
446
+ if attach:
447
+ m.add_attachment(b"%PDF-1.4 demo", maintype="application",
448
+ subtype="pdf", filename=attach)
449
+ return bytes(m)
450
+
451
+ samples = [
452
+ mail("eservice@myflcourtaccess.com", "SERVICE OF COURT DOCUMENT",
453
+ "Case Number: 562026CA000123\nCase Style: DOE v. ACME INSURANCE\n"
454
+ "Document: Motion for Summary Judgment\nServed: you@demofirm.example\n",
455
+ attach="Motion for Summary Judgment.pdf"),
456
+ mail("ecf_bounces@flsd.uscourts.gov",
457
+ "Activity in Case 2:26-cv-00123-XYZ Doe v. Acme Order on Motion",
458
+ "Document Number: 45\nDocket Text: ORDER granting in part.\n"
459
+ "https://ecf.flsd.uscourts.gov/doc1/demo (one-time free look —"
460
+ " captured, never fetched)\n"),
461
+ mail("ja@circuit19.example", "Hearing Scheduled",
462
+ "Judicial Automated Calendaring System\nCase Number: 562026CA000123\n"
463
+ "Hearing Date: 09/15/2026\nTime: 10:30 AM\nJudge: Hon. Demo Judge\n"),
464
+ mail("eservice@myflcourtaccess.com", "SERVICE OF COURT DOCUMENT",
465
+ "a redesigned portal template this adapter has never seen\n"),
466
+ mail("stranger@sketchy.example", "Invoice attached — pay today",
467
+ "wire transfer please", attach="invoice.pdf"),
468
+ ]
469
+ for raw in samples:
470
+ env = parse_message(raw, source="demo", fetched_at=st.utcnow())
471
+ msg_id = store.ingest(env, first_stage=pipeline.stages[0])
472
+ for att in store.attachments_for(msg_id):
473
+ label, tier = _classify(att["filename"])
474
+ if tier != "low":
475
+ store.stage_classification(att["id"], label, tier)
476
+ res = nmod.parse(env, adapter_stack)
477
+ if res is not None:
478
+ store.add_notice(msg_id, res.adapter, res.notice_type,
479
+ res.fields, res.missing)
480
+ status = runner.enter(msg_id)
481
+ while status == st.OK:
482
+ status = runner.advance(msg_id)
483
+ store.close()
484
+
485
+ server = make_server(home / "store", pipeline, port=args.port)
486
+ url = f"http://127.0.0.1:{server.server_address[1]}/"
487
+ print(f"Demo home: {home} (disposable)")
488
+ print(f"Two messages passed clean; three are held — a drifted portal template,")
489
+ print(f"an unknown sender, and its attachment. Release them from the dashboard:")
490
+ print(f" {url} (Ctrl-C to stop)")
491
+ if not args.no_browser:
492
+ webbrowser.open(url)
493
+ try:
494
+ server.serve_forever()
495
+ except KeyboardInterrupt:
496
+ print("\ndemo stopped; nothing was saved outside", home)
497
+
498
+
499
+ DEMO_MANIFEST = """\
500
+ [pipeline]
501
+ stages = ["ingest", "review"]
502
+
503
+ [[gate]]
504
+ id = "sender-scope"
505
+ binds_to = ["ingest"]
506
+ on_fail = "bounce"
507
+ authority = "paralegal"
508
+
509
+ [gate.options]
510
+ allow = ["@myflcourtaccess.com", "@uscourts.gov", "@circuit19.example"]
511
+
512
+ [[gate]]
513
+ id = "attachment-policy"
514
+ binds_to = ["ingest"]
515
+ on_fail = "bounce"
516
+ authority = "paralegal"
517
+
518
+ [[gate]]
519
+ id = "notice-parser"
520
+ binds_to = ["ingest"]
521
+ on_fail = "bounce"
522
+ authority = "paralegal"
523
+
524
+ [[gate]]
525
+ id = "provenance-stamp"
526
+ binds_to = ["ingest"]
527
+ on_fail = "warn"
528
+ authority = "paralegal"
529
+ """
530
+
531
+
532
+ def cmd_status(args) -> None:
533
+ _, _, store = _open(args.home)
534
+ counts = store.counts()
535
+ if not counts:
536
+ print("no messages yet")
537
+ return
538
+ for status, n in sorted(counts.items()):
539
+ print(f"{status:15} {n}")
540
+
541
+
542
+ def main(argv=None) -> None:
543
+ p = argparse.ArgumentParser(prog="docketry", description="Local gate-enforced email port")
544
+ p.add_argument("--home", default="./docketry-home", help="installation directory")
545
+ p.add_argument("--version", action="version", version=f"docketry {__version__}")
546
+ sub = p.add_subparsers(dest="cmd", required=True)
547
+
548
+ sp = sub.add_parser("init", help="set up a Docketry home directory")
549
+ sp.add_argument("--host")
550
+ sp.add_argument("--user")
551
+ sp.add_argument("--folder")
552
+ sp.add_argument("--store-password", action="store_true",
553
+ help="prompt for the password and store it in config.toml (0600)")
554
+ sp.set_defaults(fn=cmd_init)
555
+
556
+ sp = sub.add_parser("poll", help="sweep the intake mailbox once")
557
+ sp.set_defaults(fn=cmd_poll)
558
+
559
+ sp = sub.add_parser("queue", help="list messages held for review")
560
+ sp.set_defaults(fn=cmd_queue)
561
+
562
+ sp = sub.add_parser("approve", help="record an authority approval and release a hold")
563
+ sp.add_argument("message", type=int)
564
+ sp.add_argument("--gate", required=True)
565
+ sp.add_argument("--by", required=True, help="name of the approving person")
566
+ sp.add_argument("--role", required=True, help="role granted the authority in the manifest")
567
+ sp.add_argument("--note")
568
+ sp.set_defaults(fn=cmd_approve)
569
+
570
+ sp = sub.add_parser("advance", help="move a message one stage forward")
571
+ sp.add_argument("message", type=int)
572
+ sp.set_defaults(fn=cmd_advance)
573
+
574
+ sp = sub.add_parser("notices", help="list parsed court notices")
575
+ sp.add_argument("--type", choices=["service_notice", "filing_receipt", "hearing_notice"])
576
+ sp.set_defaults(fn=cmd_notices)
577
+
578
+ sp = sub.add_parser("verify-draft", help="verify every citation in a draft (exists/name/quote/pin)")
579
+ sp.add_argument("file", help="draft file (.docx, .pdf, .txt)")
580
+ sp.add_argument("--token", help="CourtListener API token (or COURTLISTENER_TOKEN env)")
581
+ sp.add_argument("--offline", action="store_true", help="extraction-only, no network")
582
+ sp.set_defaults(fn=cmd_verify_draft)
583
+
584
+ sp = sub.add_parser("lint", help="deterministic writing checks for a litigation draft")
585
+ sp.add_argument("file", help="draft file (.docx, .pdf, .txt)")
586
+ sp.add_argument("--rules", help="firm rulepack (TOML)")
587
+ sp.set_defaults(fn=cmd_lint)
588
+
589
+ sp = sub.add_parser("classify", help="classify one document (deterministic, proposes only)")
590
+ sp.add_argument("file")
591
+ sp.set_defaults(fn=cmd_classify)
592
+
593
+ sp = sub.add_parser("class-queue", help="list staged doc-type proposals")
594
+ sp.set_defaults(fn=cmd_class_queue)
595
+
596
+ sp = sub.add_parser("class-apply", help="apply a staged proposal (fill-only)")
597
+ sp.add_argument("id", type=int)
598
+ sp.add_argument("--by", required=True)
599
+ sp.add_argument("--role", required=True)
600
+ sp.set_defaults(fn=cmd_class_apply)
601
+
602
+ sp = sub.add_parser("ui", help="local review dashboard (127.0.0.1 only)")
603
+ sp.add_argument("--port", type=int, default=8642)
604
+ sp.set_defaults(fn=cmd_ui)
605
+
606
+ sp = sub.add_parser("watch", help="sweep the intake mailbox on a loop")
607
+ sp.add_argument("--every", type=int, default=300, help="seconds between sweeps")
608
+ sp.set_defaults(fn=cmd_watch)
609
+
610
+ sp = sub.add_parser("doctor", help="check the installation and say what is missing")
611
+ sp.set_defaults(fn=cmd_doctor)
612
+
613
+ sp = sub.add_parser("stats", help="queue-health stats (volumes, holds, drift, latency)")
614
+ sp.add_argument("--days", type=int, default=7)
615
+ sp.add_argument("--json", action="store_true")
616
+ sp.set_defaults(fn=cmd_stats)
617
+
618
+ sp = sub.add_parser("digest", help="print a paste-anywhere intake summary (never sends)")
619
+ sp.set_defaults(fn=cmd_digest)
620
+
621
+ sp = sub.add_parser("demo", help="seed sample traffic and open the dashboard (no mailbox needed)")
622
+ sp.add_argument("--port", type=int, default=0)
623
+ sp.add_argument("--no-browser", action="store_true")
624
+ sp.set_defaults(fn=cmd_demo)
625
+
626
+ sp = sub.add_parser("status", help="message counts by status")
627
+ sp.set_defaults(fn=cmd_status)
628
+
629
+ args = p.parse_args(argv)
630
+ args.fn(args)
631
+
632
+
633
+ if __name__ == "__main__":
634
+ main()