primarysource 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.
Files changed (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
@@ -0,0 +1,903 @@
1
+ """rfclib command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.markup import escape
15
+ from rich.table import Table
16
+
17
+ from primarysource.errors import LibError
18
+
19
+ from . import errata as errata_mod
20
+ from . import graph
21
+ from . import query as q
22
+ from .config import Paths
23
+ from .index import embed as ix_embed, hybrid as ix_hybrid, search as ix_search
24
+ from .packs import builder as pack_builder
25
+ from .packs import collections as pack_collections
26
+ from .packs import epub as pack_epub, export as pack_export
27
+ from .packs import htmlexport as pack_html, docsetexport as pack_docset
28
+ from .packs import serve as pack_serve
29
+ from .sync import downloader, scanner
30
+ from .sync.manifest import download_manifest, parse_manifest
31
+ from .sync.state import (db_stats, missing_docs, open_db, rebuild_fts,
32
+ refresh_current_flags, set_meta, upsert_docs)
33
+
34
+ app = typer.Typer(add_completion=False, no_args_is_help=True,
35
+ help="Local RFC library "
36
+ "(official RFC Editor sources: rfc-editor.org)")
37
+ console = Console()
38
+
39
+
40
+ def _version_cb(value: bool):
41
+ # eager callback: fires during option processing, i.e. before click
42
+ # demands a subcommand, so `rfclib --version` works standalone.
43
+ if value:
44
+ from . import __version__
45
+ console.print(f"rfclib {__version__}")
46
+ raise typer.Exit
47
+
48
+
49
+ @app.callback()
50
+ def _root(
51
+ version: bool = typer.Option(
52
+ False, "--version", callback=_version_cb, is_eager=True,
53
+ help="Print the rfclib version and exit."),
54
+ ):
55
+ """Root callback; --version is handled by the eager option callback."""
56
+
57
+
58
+
59
+ def _paths() -> Paths:
60
+ return Paths.default().ensure()
61
+
62
+
63
+ @contextlib.contextmanager
64
+ def _locked(paths: Paths):
65
+ """One bulk writer per library (init, sync, index build/embed)."""
66
+ from .fsutil import LockHeld, bulk_lock
67
+ try:
68
+ with bulk_lock(paths.home):
69
+ yield
70
+ except LockHeld as e:
71
+ console.print(f"[red]{escape(str(e))}[/red]")
72
+ raise typer.Exit(code=1)
73
+
74
+
75
+ def _legacy_hint() -> None:
76
+ from .config import legacy_home
77
+ old = legacy_home()
78
+ if old:
79
+ console.print(f"[yellow]note:[/yellow] a library from an older rfclib "
80
+ f"exists at {old}; this command uses "
81
+ f"{Paths.default().home}. To keep using the old one: "
82
+ f"set RFCLIB_HOME={old}")
83
+
84
+
85
+ def _db(paths: Paths):
86
+ return open_db(paths.db)
87
+
88
+
89
+ def _did(raw: str) -> str:
90
+ """normalize_doc_id with a clean CLI error instead of a traceback."""
91
+ try:
92
+ return q.normalize_doc_id(raw)
93
+ except ValueError as e:
94
+ raise typer.BadParameter(str(e))
95
+
96
+
97
+ def _load_manifest(paths: Paths, conn, refresh: bool) -> None:
98
+ dest = download_manifest(paths.manifest_xml, force=refresh)
99
+ docs = parse_manifest(dest)
100
+ n_docs, n_rel = upsert_docs(conn, docs)
101
+ refresh_current_flags(conn)
102
+ n_fts = rebuild_fts(conn)
103
+ set_meta(conn, "manifest_synced_at",
104
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
105
+ console.print(f"[green]manifest indexed:[/green] {n_docs} docs, "
106
+ f"{n_rel} relations, {n_fts} FTS rows (source: {dest})")
107
+
108
+
109
+ @app.command()
110
+ def manifest(refresh: bool = typer.Option(False, "--refresh",
111
+ help="Force re-download of rfc-index.xml")):
112
+ """Fetch/parse the official RFC Index manifest into the local DB."""
113
+ paths = _paths()
114
+ conn = _db(paths)
115
+ _load_manifest(paths, conn, refresh)
116
+
117
+
118
+ @app.command()
119
+ def init(content: str = typer.Option(
120
+ "none", "--content",
121
+ help="also prefetch documents: none (catalog only; RFCs are "
122
+ "fetched on first use) | current | all"),
123
+ subjects: bool = typer.Option(
124
+ True, "--subjects/--no-subjects",
125
+ help="crawl the RFC Editor subject tree (~640 pages, cached)"),
126
+ embed: bool = typer.Option(
127
+ True, "--embed/--no-embed",
128
+ help="with --content: build semantic vectors (~30 MB model)"),
129
+ refresh: bool = typer.Option(
130
+ False, "--refresh", help="re-fetch catalog data even if present")):
131
+ """Set up the library: catalog first, documents optional; re-runnable.
132
+
133
+ The catalog (RFC index, errata, subject tree) makes status, search by
134
+ document, browse and errata work for every RFC; documents are then
135
+ fetched on first use. Finished steps are skipped on a re-run."""
136
+ from . import library
137
+ if content not in ("none", "current", "all"):
138
+ raise typer.BadParameter("--content must be none, current or all")
139
+ _legacy_hint()
140
+ paths = _paths()
141
+ conn = _db(paths)
142
+ console.print(f"library: [bold]{paths.home}[/bold]")
143
+ t0 = time.time()
144
+ say = lambda m: console.print(f" {m}", highlight=False) # noqa: E731
145
+ with _locked(paths):
146
+ out = library.init_catalog(conn, paths, subjects=subjects,
147
+ refresh=refresh, say=say)
148
+ if content != "none":
149
+ out["content"] = library.init_content(conn, paths, content,
150
+ embed=embed, say=say)
151
+ console.print(f"[green]done[/green] in {time.time() - t0:.0f}s: "
152
+ f"{json.dumps(out, default=str)[:400]}")
153
+ console.print(escape("next: `rfclib status`; MCP clients run "
154
+ "`uvx --from 'rfclib[mcp,semantic]' rfclib mcp`"),
155
+ highlight=False)
156
+
157
+
158
+ @app.command()
159
+ def sync(formats: str = typer.Option("txt", help="comma list: txt,html,xml,pdf,json"),
160
+ limit: int = typer.Option(0, help="max documents this run (0 = all)"),
161
+ include_obsolete: bool = typer.Option(False, help="also fetch obsolete RFCs"),
162
+ concurrency: int = typer.Option(8, min=1, max=16),
163
+ force: bool = typer.Option(False, help="re-download even if present"),
164
+ refresh_manifest: bool = typer.Option(False, "--refresh-manifest")):
165
+ """Incremental sync: fetch documents missing from the local mirror."""
166
+ paths = _paths()
167
+ conn = _db(paths)
168
+ fmts = [f.strip().lower() for f in formats.split(",") if f.strip()]
169
+ bad = [f for f in fmts if f not in downloader.VALID_FORMATS]
170
+ if bad:
171
+ raise typer.BadParameter(f"unsupported formats: {bad}")
172
+ if refresh_manifest or conn.execute(
173
+ "SELECT COUNT(*) c FROM docs").fetchone()["c"] == 0:
174
+ _load_manifest(paths, conn, refresh=True)
175
+
176
+ missing = missing_docs(conn, fmts, include_obsolete=include_obsolete)
177
+ if limit:
178
+ missing = missing[:limit]
179
+ if not missing:
180
+ console.print("[green]mirror already complete[/green] for requested formats")
181
+ return
182
+ console.print(f"syncing [bold]{len(missing)}[/bold] docs ({'+'.join(fmts)}) "
183
+ "from rfc-editor.org ...")
184
+ t0 = time.time()
185
+ with _locked(paths):
186
+ results = downloader.fetch_docs(conn, paths.mirror, missing, fmts,
187
+ concurrency=concurrency, force=force)
188
+ conn.commit()
189
+ ok = sum(1 for _, _, s in results if s == "ok")
190
+ miss = sum(1 for _, _, s in results if s.startswith("missing"))
191
+ err = [r for r in results if r[2].startswith("error")]
192
+ console.print(f"[green]ok={ok}[/green] missing_upstream={miss} "
193
+ f"errors={len(err)} in {time.time()-t0:.1f}s "
194
+ f"({len(missing)} docs requested)")
195
+ if err:
196
+ for d, f, s in err[:10]:
197
+ console.print(f" [red]ERR[/red] {d}.{f}: {s}")
198
+ raise typer.Exit(code=1)
199
+
200
+
201
+ @app.command()
202
+ def scan(mirror_path: Optional[Path] = typer.Option(
203
+ None, exists=True, file_okay=False,
204
+ help="rsync'd mirror root (default: <home>/mirror)")):
205
+ """Register files from an rsync-produced mirror into the DB (post-rsync bootstrap)."""
206
+ paths = _paths()
207
+ conn = _db(paths)
208
+ root = Path(mirror_path) if mirror_path else paths.mirror
209
+ stats = scanner.scan_mirror(conn, root)
210
+ console.print(f"scan of {root}: {json.dumps(stats)}")
211
+
212
+
213
+ @app.command()
214
+ def status():
215
+ """What the library holds and which tier it is at."""
216
+ from . import library
217
+ _legacy_hint()
218
+ paths = _paths()
219
+ conn = _db(paths)
220
+ st = library.state(conn)
221
+ stats = db_stats(conn)
222
+ table = Table(title=f"rfclib @ {paths.home}")
223
+ table.add_column("key", style="cyan")
224
+ table.add_column("value")
225
+ if not st["docs"]:
226
+ table.add_row("state", "[yellow]empty: run `rfclib init`[/yellow]")
227
+ table.add_row("network", f"{st['network']} (RFCLIB_NETWORK)")
228
+ feats = library.features()
229
+ table.add_row("optional features", " ".join(
230
+ f"{k}: {'yes' if v else 'no'}" for k, v in feats.items())
231
+ + ("" if all(feats.values()) else
232
+ escape(" (pip install 'rfclib[mcp,semantic]')")))
233
+ table.add_row("documents (catalog)",
234
+ f"{st['docs']:,} ({st['rfcs']:,} RFCs, "
235
+ f"{st['current_rfcs']:,} current)")
236
+ table.add_row("catalog synced", str(st["manifest_synced_at"]))
237
+ table.add_row("errata", f"{st['errata']:,} (synced "
238
+ f"{st['errata_synced_at'] or 'never'})")
239
+ table.add_row("subjects", f"{st['subjects']} (synced "
240
+ f"{st['subjects_synced_at'] or 'never'})")
241
+ table.add_row("local files", json.dumps(stats["files_by_fmt"]))
242
+ table.add_row("local bytes", f"{stats['files_bytes']:,}")
243
+ table.add_row("section-indexed",
244
+ f"{st['indexed']:,} docs ({st['indexed_current']:,} of "
245
+ f"{st['current_rfcs']:,} current RFCs)")
246
+ table.add_row("embedded sections", f"{st['embedded_chunks']:,}")
247
+ from .index import embed as ix_embed
248
+ table.add_row("embedding model",
249
+ "in library" if (ix_embed.model_dir() / "config.json")
250
+ .is_file() else "not downloaded")
251
+ table.add_row("newest pub_date", str(stats["newest_pub_date"]))
252
+ console.print(table)
253
+
254
+
255
+ @app.command()
256
+ def search(query: str, limit: int = 10, include_obsolete: bool = False,
257
+ body: bool = typer.Option(
258
+ False, "--body", "-b",
259
+ help="full-text search INSIDE documents (needs `rfclib index build`)")):
260
+ """Search titles/abstracts/keywords; --body searches section-level full text."""
261
+ paths = _paths()
262
+ conn = _db(paths)
263
+ if body:
264
+ for h in ix_search.search_chunks(conn, query, limit=limit,
265
+ include_obsolete=include_obsolete):
266
+ sec = f" §{h['sec_num']}" if h["sec_num"] else ""
267
+ console.print(f"[bold]{h['doc_id']}[/bold]{sec} "
268
+ f"{escape(h['sec_title'])} "
269
+ f"[dim](lines {h['start_line']}-{h['end_line']})[/dim]")
270
+ console.print(f" {escape(h['snippet'])}")
271
+ return
272
+ for h in q.search(conn, query, limit=limit, include_obsolete=include_obsolete):
273
+ flag = "" if h["is_current"] else " [red](obsolete)[/red]"
274
+ console.print(f"[bold]{h['doc_id']}[/bold] {h['pub_date']} "
275
+ f"[{h['current_status']}] " + escape(str(h["title"])) + flag)
276
+
277
+
278
+ @app.command("vsearch")
279
+ def vsearch(query: str, limit: int = 10, include_obsolete: bool = False):
280
+ """Semantic (embedding) search inside documents (needs `rfclib index embed`)."""
281
+ conn = _db(_paths())
282
+ try:
283
+ hits = ix_embed.semantic_search(conn, query, limit=limit,
284
+ include_obsolete=include_obsolete)
285
+ except RuntimeError as e:
286
+ console.print(f"[red]{e}[/red]")
287
+ raise typer.Exit(code=1)
288
+ for h in hits:
289
+ sec = f" §{h['sec_num']}" if h["sec_num"] else ""
290
+ console.print(f"[bold]{h['doc_id']}[/bold]{sec} "
291
+ f"{escape(h['sec_title'])} "
292
+ f"[dim](lines {h['start_line']}-{h['end_line']}, "
293
+ f"d={h['distance']:.3f})[/dim]")
294
+ console.print(f" {escape(h['preview'])[:200]}")
295
+
296
+
297
+ @app.command("hsearch")
298
+ def hsearch(query: str, limit: int = 10, include_obsolete: bool = False):
299
+ """Hybrid retrieval: BM25 + embeddings fused (RRF) with status prior.
300
+
301
+ Recommended default for open questions: exact-term hits from FTS5 are
302
+ merged with concept hits from the vector index; standards-track and
303
+ BCP/STD documents get a ranking boost. Shows which list(s) backed each
304
+ hit (bm25+knn+title = all three agree; title = doc's own title matched).
305
+ """
306
+ conn = _db(_paths())
307
+ try:
308
+ hits = ix_hybrid.hybrid_search(conn, query, limit=limit,
309
+ include_obsolete=include_obsolete)
310
+ except RuntimeError as e:
311
+ console.print(f"[red]{e}[/red]")
312
+ raise typer.Exit(code=1)
313
+ if not hits:
314
+ console.print("[yellow]no hits[/yellow]")
315
+ return
316
+ for h in hits:
317
+ sec = f" §{h['sec_num']}" if h["sec_num"] else ""
318
+ console.print(
319
+ f"[bold]{h['doc_id']}[/bold]{sec} {escape(h['sec_title'])} "
320
+ f"[dim](lines {h['start_line']}-{h['end_line']}, "
321
+ f"rrf={h['rrf']:.4f}, {h['sources']})[/dim]")
322
+ console.print(f" {escape(h['snippet'])[:200]}")
323
+
324
+
325
+ @app.command()
326
+ def get(doc_id: str, fmt: str = "txt", lines: int = 40, start_line: int = 1):
327
+ """Print a document (first lines by default)."""
328
+ paths = _paths()
329
+ conn = _db(paths)
330
+ try:
331
+ doc = q.get_document(conn, _did(doc_id), fmt=fmt,
332
+ mirror_root=paths.mirror, start_line=start_line,
333
+ max_lines=lines)
334
+ except q.NotFound:
335
+ console.print(f"[red]{doc_id} not found[/red] — run "
336
+ "`rfclib manifest --refresh` if it is very new.")
337
+ raise typer.Exit(code=1)
338
+ if doc.get("content") is None:
339
+ console.print(f"[yellow]{doc.get('note') or doc.get('metadata')}[/yellow]")
340
+ raise typer.Exit(code=1)
341
+ console.print(f"# {doc['doc_id']} via {doc['path']} (lines "
342
+ f"{doc['start_line']}-{doc['end_line']} of {doc['total_lines']})")
343
+ console.print(doc["content"], markup=False, highlight=False)
344
+
345
+
346
+ @app.command()
347
+ def meta(doc_id: str):
348
+ """Full metadata + relations as JSON."""
349
+ paths = _paths()
350
+ conn = _db(paths)
351
+ try:
352
+ console.print_json(json.dumps(
353
+ q.get_metadata(conn, _did(doc_id)), default=str))
354
+ except q.NotFound:
355
+ console.print(f"[red]{doc_id} not found[/red]")
356
+ raise typer.Exit(code=1)
357
+
358
+
359
+ @app.command()
360
+ def rels(doc_id: str):
361
+ """Show the relation graph for a document."""
362
+ paths = _paths()
363
+ conn = _db(paths)
364
+ try:
365
+ m = q.get_metadata(conn, _did(doc_id))
366
+ except q.NotFound:
367
+ console.print(f"[red]{doc_id} not found[/red]")
368
+ raise typer.Exit(code=1)
369
+ if not m["relations"]:
370
+ console.print("no relations")
371
+ for kind, targets in sorted(m["relations"].items()):
372
+ console.print(f" {kind}: {', '.join(targets)}")
373
+
374
+
375
+ @app.command()
376
+ def refs(doc_id: Optional[str] = typer.Argument(
377
+ None, help="document to inspect; omit when using --build"),
378
+ build: bool = typer.Option(
379
+ False, "--build",
380
+ help="mine the citation graph from local txt files "
381
+ "(incremental by content hash)"),
382
+ force: bool = typer.Option(False, "--force",
383
+ help="re-mine unchanged docs too"),
384
+ limit: int = typer.Option(40, help="peers shown per direction")):
385
+ """Citation graph: what an RFC cites, and what cites it.
386
+
387
+ Includes normative/informative tags from References sections and
388
+ section-level anchors mined from 'Section N.M of [RFCX]' phrasing.
389
+ """
390
+ conn = _db(_paths())
391
+ if build:
392
+ t0 = time.time()
393
+ res = graph.build(conn, force=force)
394
+ console.print(f"mined {res['built']} docs ({res['new_edges']} new "
395
+ f"edges), {res['skipped_unchanged']} unchanged; "
396
+ f"{res['total_edges']} total edges "
397
+ f"in {time.time() - t0:.0f}s")
398
+ if doc_id is None:
399
+ raise typer.Exit(code=0)
400
+ if doc_id is None:
401
+ console.print("[yellow]pass a DOC_ID or --build[/yellow]")
402
+ raise typer.Exit(code=2)
403
+ did = _did(doc_id)
404
+ r = graph.get_refs(conn, did, limit=limit)
405
+ if not r["built"]:
406
+ console.print("[yellow]citation graph is empty; run "
407
+ "`rfclib refs --build` first[/yellow]")
408
+ raise typer.Exit(code=1)
409
+
410
+ def show(rows: list[dict], label: str):
411
+ console.print(f"[bold]{label} ({len(rows)})[/bold]")
412
+ for e in rows:
413
+ secs = (" §" + ", §".join(e["sections"])) if e["sections"] else ""
414
+ obsolete = "" if e["is_current"] else " [red](obs)[/red]"
415
+ console.print(
416
+ f" [cyan]{e['doc_id']:<10}[/cyan]"
417
+ f"[dim]{'+'.join(e['kinds'])}[/dim]"
418
+ f"[magenta]{secs[:44]}[/magenta]"
419
+ f" {escape((e['title'] or '')[:56])}{obsolete}")
420
+
421
+ show(r["outbound"], f"{did} cites")
422
+ console.print()
423
+ show(r["inbound"], f"cited by {did}")
424
+
425
+
426
+ @app.command()
427
+ def errata(doc_id: str = typer.Argument(
428
+ "", help="document id; omit with --sync-all"),
429
+ refresh: bool = typer.Option(
430
+ False, "--refresh", help="re-fetch from the errata service"),
431
+ all_statuses: bool = typer.Option(
432
+ False, "--all",
433
+ help="also show Reported/Held/Rejected, not just Verified"),
434
+ sync_all: bool = typer.Option(
435
+ False, "--sync-all",
436
+ help="load every erratum from the RFC Editor bulk API "
437
+ "(~12 MB, one request)")):
438
+ """Verified-errata overlay from the RFC Editor errata service.
439
+
440
+ --sync-all loads the bulk feed once so every document's errata status
441
+ is known offline; otherwise fetches on demand (one polite request per
442
+ document) and caches locally.
443
+ """
444
+ conn = _db(_paths())
445
+ if sync_all:
446
+ console.print(json.dumps(errata_mod.sync_bulk(conn)))
447
+ return
448
+ if not doc_id:
449
+ raise typer.BadParameter("give a document id, or --sync-all")
450
+ did = q.resolve(conn, _did(doc_id))
451
+ try:
452
+ res = errata_mod.ensure(conn, did, refresh=refresh)
453
+ except Exception as e: # noqa: BLE001 - network layer errors vary
454
+ text = e.as_text() if isinstance(e, LibError) else str(e)
455
+ console.print(f"[red]errata fetch failed:[/red] {escape(text)}")
456
+ raise typer.Exit(code=1)
457
+ rows = res["results"]
458
+ verified = [e for e in rows if e["status"] == "Verified"]
459
+ other = [e for e in rows if e["status"] != "Verified"]
460
+ console.print(f"[bold]{did}[/bold]: {len(verified)} verified errata "
461
+ f"({len(other)} other status) "
462
+ f"[dim]source={res['source']}"
463
+ f"{', fetched ' + res['fetched_at'] if res.get('fetched_at') else ''}[/dim]")
464
+
465
+ def show(entries: list[dict], label: str):
466
+ if not entries:
467
+ return
468
+ console.print(f"[bold]{label}[/bold]")
469
+ for e in entries:
470
+ sec = f" §{e['section']}" if e["section"] else ""
471
+ excerpt = ((e["should_say"] or e["says"] or e["notes"]
472
+ or "").splitlines() or [""])[0]
473
+ console.print(
474
+ f" [cyan]eid {e['eid']}[/cyan] [dim]{e['status']} · "
475
+ f"{e['type'] or '?'}[/dim][magenta]{sec}[/magenta] "
476
+ f"{escape(excerpt[:70])}")
477
+
478
+ show(verified, "Verified")
479
+ if all_statuses:
480
+ show(other, "Other statuses")
481
+
482
+
483
+ @app.command("resolve")
484
+ def resolve_cmd(doc_id: str):
485
+ """Resolve a BCP/STD/FYI alias to its content-bearing RFC."""
486
+ paths = _paths()
487
+ conn = _db(paths)
488
+ did = _did(doc_id)
489
+ try:
490
+ target = q.resolve(conn, did)
491
+ except q.NotFound:
492
+ console.print(f"[red]{did} not found[/red]")
493
+ raise typer.Exit(code=1)
494
+ console.print(f"{did} -> {target}")
495
+
496
+
497
+ @app.command()
498
+ def toc(doc_id: str):
499
+ """Section outline with exact line ranges (use with `get --start_line`)."""
500
+ conn = _db(_paths())
501
+ did = _did(doc_id)
502
+ rows = ix_search.get_toc(conn, did)
503
+ if not rows:
504
+ console.print(f"[yellow]no chunks for {did}; "
505
+ "run `rfclib index build`[/yellow]")
506
+ raise typer.Exit(code=1)
507
+ console.print(f"[bold]{did}[/bold] outline:")
508
+ for r in rows:
509
+ console.print(f" [cyan]{r['sec_num'] or '-':<12}[/cyan]"
510
+ f"{escape(r['sec_title'])[:60]:<62}"
511
+ f"[dim]lines {r['start_line']}-{r['end_line']}[/dim]")
512
+
513
+
514
+ @app.command("index")
515
+ def index_cmd(action: str = typer.Argument("status", help="build | embed | status"),
516
+ limit: int = typer.Option(0, help="max chunks to embed (0 = all)"),
517
+ model: str = typer.Option("minishlab/potion-base-8M", "--model",
518
+ help="model2vec model id for embed")):
519
+ """Maintain retrieval indexes: build=FTS chunks, embed=vectors."""
520
+ paths = _paths()
521
+ conn = _db(paths)
522
+ t0 = time.time()
523
+ lock = (_locked(paths) if action in ("build", "embed")
524
+ else contextlib.nullcontext())
525
+ with lock:
526
+ try:
527
+ st = _index_action(action, conn, paths, model, limit)
528
+ except RuntimeError as e: # e.g. rfclib[semantic] not installed
529
+ console.print(f"[red]{escape(str(e))}[/red]")
530
+ raise typer.Exit(code=1)
531
+ st["seconds"] = round(time.time() - t0, 1)
532
+ console.print(json.dumps(st))
533
+
534
+
535
+ def _index_action(action: str, conn, paths: Paths, model: str,
536
+ limit: int) -> dict:
537
+ if action == "build":
538
+ st = ix_search.build_chunks(
539
+ conn, paths.mirror,
540
+ progress=lambda d, t: console.print(
541
+ f" chunked {d}/{t} docs", end="\r", highlight=False))
542
+ console.print()
543
+ elif action == "embed":
544
+ st = ix_embed.embed_pending(
545
+ conn, model_name=model, limit=limit,
546
+ progress=lambda d, t: console.print(
547
+ f" embedded {d}/{t} chunks", end="\r", highlight=False))
548
+ console.print()
549
+ else:
550
+ st = ix_search.index_stats(conn)
551
+ return st
552
+
553
+
554
+ MCP_HTTP_PORT = 8765
555
+ _LOOPBACK = ("127.0.0.1", "localhost", "::1")
556
+
557
+
558
+ @app.command()
559
+ def mcp(
560
+ transport: str = typer.Option(
561
+ "stdio", "--transport",
562
+ help="stdio: one server per client (default). http: one shared "
563
+ "server for every client (MCP streamable HTTP at /mcp)."),
564
+ host: str = typer.Option(
565
+ "127.0.0.1", "--host",
566
+ help="http: address to bind. There is no authentication: keep it "
567
+ "on loopback, or put an authenticating reverse proxy in front."),
568
+ port: int = typer.Option(MCP_HTTP_PORT, "--port", help="http: TCP port."),
569
+ allowed_host: list[str] = typer.Option(
570
+ None, "--allowed-host",
571
+ help="http, with a non-loopback --host: a Host header value to "
572
+ "accept, e.g. 'localhost:*' or 'rfc.example.net'. Repeatable. "
573
+ "Without it the Host/Origin check is off."),
574
+ read_only: bool = typer.Option(
575
+ False, "--read-only",
576
+ help="Never write the library: open the database read-only and "
577
+ "never fetch (same as RFCLIB_READONLY=1). For extra stdio "
578
+ "servers beside a shared http server."),
579
+ ):
580
+ """Run the MCP server: on stdio for one client, or as one shared HTTP
581
+ server (--transport http) that every client connects to by URL."""
582
+ import logging
583
+ import os
584
+ transport = transport.strip().lower()
585
+ if transport in ("streamable-http", "streamable_http"):
586
+ transport = "http"
587
+ if transport not in ("stdio", "http"):
588
+ Console(stderr=True).print(
589
+ f"[red]unknown --transport {escape(transport)}: use stdio or "
590
+ "http[/red]")
591
+ raise typer.Exit(code=2)
592
+ if read_only:
593
+ os.environ["RFCLIB_READONLY"] = "1"
594
+ # stdout is the JSON-RPC channel; stderr is the client's log. Per-request
595
+ # HTTP lines from httpx/huggingface_hub would flood it.
596
+ for name in ("httpx", "httpcore", "huggingface_hub"):
597
+ logging.getLogger(name).setLevel(logging.WARNING)
598
+ try:
599
+ from .mcp.server import mcp as mcp_server
600
+ except ImportError as e:
601
+ if "mcp" not in str(e).lower():
602
+ raise
603
+ # stderr: stdout is the JSON-RPC channel the client is reading
604
+ # escape(): rich would read "[mcp,semantic]" as a markup tag
605
+ Console(stderr=True).print(
606
+ "[red]The MCP server is an optional feature and is not "
607
+ "installed.[/red]\n" + escape(
608
+ " pip install 'rfclib[mcp,semantic]'\n"
609
+ " or run it with: uvx --from 'rfclib[mcp,semantic]' "
610
+ "rfclib mcp"), highlight=False)
611
+ raise typer.Exit(code=2)
612
+ from primarysource import serve
613
+ if transport == "http":
614
+ Console(stderr=True).print(
615
+ f"rfclib mcp: http://{host}:{port}/mcp (library "
616
+ f"{escape(str(Paths.default().home))}"
617
+ f"{', read-only' if read_only else ''})", highlight=False)
618
+ serve.run(mcp_server, transport, host=host, port=port,
619
+ allowed_hosts=allowed_host or ())
620
+
621
+
622
+ subjects_app = typer.Typer(
623
+ help="RFC Editor subject taxonomy (rfc-editor.org/subjects): "
624
+ "browse the collection by curated topic tree.")
625
+ app.add_typer(subjects_app, name="subjects")
626
+
627
+
628
+ @subjects_app.command("sync")
629
+ def subjects_sync(
630
+ force: bool = typer.Option(
631
+ False, "--force", help="re-download the subject pages"),
632
+ limit: int = typer.Option(
633
+ 0, "--limit", help="crawl only the first N subjects (smoke run)"),
634
+ delay: float = typer.Option(
635
+ 0.1, help="polite inter-request delay, seconds"),
636
+ ):
637
+ """Mirror the subject tree and per-subject document lists (~640 pages)."""
638
+ from . import subjects as subj_mod
639
+ paths = _paths()
640
+ conn = _db(paths)
641
+ t0 = time.time()
642
+ res = subj_mod.sync(
643
+ conn, paths, force=force, delay=delay, limit=limit,
644
+ progress=lambda m: console.print(f" {m}", highlight=False))
645
+ console.print(
646
+ f"[green]subjects synced[/green]: {res['subjects']} subjects, "
647
+ f"{res['assignments']} assignments over {res['distinct_docs']} docs "
648
+ f"({time.time() - t0:.1f}s)")
649
+ if res["failed"]:
650
+ console.print(f"[yellow]failed pages ({len(res['failed'])}):[/yellow] "
651
+ + ", ".join(res["failed"][:10]))
652
+
653
+
654
+ @subjects_app.command("list")
655
+ def subjects_list(
656
+ contains: Optional[str] = typer.Argument(
657
+ None, help="substring filter on slug/name/description"),
658
+ descriptions: bool = typer.Option(
659
+ False, "--descriptions", help="show the RFC Editor blurbs"),
660
+ ):
661
+ """Show the subject tree (or a filtered slice), parents before children."""
662
+ from . import subjects as subj_mod
663
+ conn = _db(_paths())
664
+ if not subj_mod.subjects_ready(conn):
665
+ console.print("[red]taxonomy not synced; run `rfclib subjects sync`[/red]")
666
+ raise typer.Exit(code=1)
667
+ rows = subj_mod.list_subjects(conn, contains=contains)
668
+ for r in rows:
669
+ line = (" " * r["depth"] + "[dim]" + " " * r["depth"]
670
+ + escape(r["slug"]) + f"[/dim] {escape(r['name'])}"
671
+ + f" [cyan]{r['doc_count'] if r['doc_count'] is not None else '?'}[/cyan]")
672
+ console.print(line, highlight=False)
673
+ if descriptions and r["description"]:
674
+ console.print(" " * (r["depth"] + 1)
675
+ + f"[dim]{escape(r['description'])}[/dim]",
676
+ highlight=False)
677
+ console.print(f"[bold]{len(rows)} subjects[/bold]")
678
+
679
+
680
+ @subjects_app.command("get")
681
+ def subjects_get(
682
+ name: str = typer.Argument(..., help="subject slug or name, e.g. quic"),
683
+ limit: int = typer.Option(40, help="max docs to show"),
684
+ include_obsolete: bool = typer.Option(False),
685
+ ):
686
+ """List the documents inside one subject (incl. descendants), newest first."""
687
+ from . import subjects as subj_mod
688
+ conn = _db(_paths())
689
+ try:
690
+ data = subj_mod.get_subject(conn, name,
691
+ include_obsolete=include_obsolete,
692
+ limit=limit)
693
+ except subj_mod.SubjectNotFound as e:
694
+ console.print(f"[red]{escape(str(e))}[/red]")
695
+ raise typer.Exit(code=1)
696
+ s = data["subject"]
697
+ console.print(f"[bold]{escape(s['name'])}[/bold] ({s['slug']})"
698
+ + (f" - {escape(s['description'])}" if s["description"] else ""))
699
+ if data["children"]:
700
+ console.print("[dim]children:[/dim] " + ", ".join(
701
+ f"{c['slug']} ({c['doc_count']})" for c in data["children"]))
702
+ for d in data["docs"]:
703
+ console.print(
704
+ f" {d['doc_id']:<9} {(d['pub_date'] or '-'):<10} "
705
+ f"{(d['current_status'] or '')[:18]:<18} {escape(d['title'] or '')}",
706
+ highlight=False)
707
+ extra = f", next offset {data['next_offset']}" if data["next_offset"] else ""
708
+ console.print(f"{len(data['docs'])} shown of {data['docs_total']} docs{extra}")
709
+
710
+
711
+ packs_app = typer.Typer(help="Build & serve curated RFC content packs (.rfcpack).")
712
+ app.add_typer(packs_app, name="packs")
713
+
714
+
715
+ def _collection_dirs(collections_dir: Optional[Path]) -> list[Path]:
716
+ dirs = []
717
+ if collections_dir:
718
+ dirs.append(Path(collections_dir))
719
+ dirs.append(_paths().home / "collections")
720
+ return dirs
721
+
722
+
723
+ @packs_app.command("list")
724
+ def packs_list(collections_dir: Optional[Path] = typer.Option(
725
+ None, "--collections-dir", exists=True, file_okay=False,
726
+ help="extra dir with user <id>.json collection manifests")):
727
+ """Show available collections and how many docs each resolves to."""
728
+ conn = _db(_paths())
729
+ for coll in pack_collections.list_collections(_collection_dirs(collections_dir)):
730
+ ids = pack_collections.resolve(conn, coll)
731
+ console.print(f"[bold]{coll.id:<20}[/bold] {len(ids):>5} docs "
732
+ f"{escape(coll.name)}")
733
+
734
+
735
+ @packs_app.command("build")
736
+ def packs_build(
737
+ names: list[str] = typer.Argument(None, help="collection ids"),
738
+ all_collections: bool = typer.Option(
739
+ False, "--all", help="build every listed collection"),
740
+ formats: str = typer.Option(
741
+ "txt", help="comma-separated formats: txt,html (txt enables search)"),
742
+ search: bool = typer.Option(
743
+ True, "--search/--no-search",
744
+ help="embed an offline FTS5 search index (search.sqlite)"),
745
+ out: Path = typer.Option(
746
+ Path("export-packs"), "--out", file_okay=False,
747
+ help="output dir for .rfcpack files + catalog.json"),
748
+ collections_dir: Optional[Path] = typer.Option(
749
+ None, "--collections-dir", exists=True, file_okay=False),
750
+ ):
751
+ """Build .rfcpack files for one or more collections."""
752
+ conn = _db(_paths())
753
+ dirs = _collection_dirs(collections_dir)
754
+ if all_collections:
755
+ names = [c.id for c in pack_collections.list_collections(dirs)]
756
+ if not names:
757
+ console.print("[red]give collection ids or --all[/red]")
758
+ raise typer.Exit(code=2)
759
+ fmts = [f.strip() for f in formats.split(",") if f.strip()]
760
+ t0 = time.time()
761
+ results = []
762
+ for name in names:
763
+ path = pack_collections.find_collection(name, dirs)
764
+ coll = pack_collections.Collection.from_json(
765
+ json.loads(path.read_text("utf-8")), fallback_id=path.stem)
766
+ try:
767
+ res = pack_builder.build_pack(
768
+ conn, coll, out, formats=fmts, search=search,
769
+ progress=lambda m: console.print(f" {coll.id}: {m}",
770
+ highlight=False))
771
+ except pack_builder.MissingFiles as e:
772
+ console.print(f"[red]{coll.id}: {e}[/red]")
773
+ raise typer.Exit(code=1)
774
+ console.print(
775
+ f"[green]{coll.id}[/green]: {res['docs']} docs, "
776
+ f"{res['chunks']} chunks, {res['bytes'] / 1e6:.1f} MB "
777
+ f"-> {res['path']}")
778
+ results.append(res)
779
+ cat = pack_serve.write_catalog(out)
780
+ console.print(f"catalog: {len(cat['packs'])} packs in {out} "
781
+ f"({time.time() - t0:.1f}s)")
782
+
783
+
784
+ @packs_app.command("export")
785
+ def packs_export(
786
+ names: list[str] = typer.Argument(None, help="collection ids"),
787
+ all_collections: bool = typer.Option(
788
+ False, "--all", help="export every listed collection"),
789
+ formats: str = typer.Option(
790
+ "epub,html", help="comma list of: epub, html, zim, docset"),
791
+ out: Path = typer.Option(
792
+ Path("export-formats"), "--out", file_okay=False,
793
+ help="output dir for .epub/.zim files / html mirrors"),
794
+ link_refs: str = typer.Option(
795
+ "internal", help="link 'RFC 9000' refs: none|internal|external"),
796
+ collections_dir: Optional[Path] = typer.Option(
797
+ None, "--collections-dir", exists=True, file_okay=False),
798
+ ):
799
+ """Export collections for third-party readers (EPUB, HTML mirror, ZIM, docset).
800
+
801
+ The zim format needs the optional native bindings (`uv sync --extra
802
+ zim`); epub/html/docset are pure stdlib. The .rfcpack format stays
803
+ the native one for this app's own Android reader (`packs build`).
804
+ """
805
+ fmts = [f.strip().lower() for f in formats.split(",") if f.strip()]
806
+ bad = [f for f in fmts if f not in ("epub", "html", "zim", "docset")]
807
+ if bad:
808
+ console.print(f"[red]unknown format(s): {', '.join(bad)} "
809
+ "(available: epub, html, zim, docset)[/red]")
810
+ raise typer.Exit(code=2)
811
+ if link_refs not in ("none", "internal", "external"):
812
+ console.print("[red]--link-refs must be none|internal|external[/red]")
813
+ raise typer.Exit(code=2)
814
+ pack_zim = None
815
+ if "zim" in fmts:
816
+ from .packs import zimexport as pack_zim
817
+ if not pack_zim.HAVE_LIBZIM:
818
+ console.print("[red]zim format needs the optional native "
819
+ "bindings: uv sync --extra zim[/red]")
820
+ raise typer.Exit(code=2)
821
+ conn = _db(_paths())
822
+ dirs = _collection_dirs(collections_dir)
823
+ if all_collections:
824
+ names = [c.id for c in pack_collections.list_collections(dirs)]
825
+ if not names:
826
+ console.print("[red]give collection ids or --all[/red]")
827
+ raise typer.Exit(code=2)
828
+ t0 = time.time()
829
+ for name in names:
830
+ path = pack_collections.find_collection(name, dirs)
831
+ coll = pack_collections.Collection.from_json(
832
+ json.loads(path.read_text("utf-8")), fallback_id=path.stem)
833
+ say = lambda m, cid=coll.id: console.print( # noqa: E731
834
+ f" {cid}: {m}", highlight=False)
835
+ try:
836
+ docs = pack_export.iter_export_docs(conn, coll, progress=say)
837
+ except pack_export.MissingFiles as e:
838
+ console.print(f"[red]{coll.id}: {e}[/red]")
839
+ raise typer.Exit(code=1)
840
+ for fmt in fmts:
841
+ builder = (pack_epub.build_epub if fmt == "epub"
842
+ else pack_html.build_html if fmt == "html"
843
+ else pack_docset.build_docset if fmt == "docset"
844
+ else pack_zim.build_zim)
845
+ res = builder(coll, docs, out, link_refs=link_refs, progress=say)
846
+ console.print(
847
+ f"[green]{coll.id}[/green] {fmt}: {res['docs']} docs, "
848
+ f"{res['bytes'] / 1e6:.1f} MB -> {res['path']}")
849
+ console.print(f"done in {time.time() - t0:.1f}s")
850
+
851
+
852
+ @packs_app.command("catalog")
853
+ def packs_catalog(out: Path = typer.Option(
854
+ Path("export-packs"), "--out", file_okay=False,
855
+ help="dir containing .rfcpack files")):
856
+ """(Re)build catalog.json with sha256/size for every pack in a dir."""
857
+ cat = pack_serve.write_catalog(out)
858
+ for p in cat["packs"]:
859
+ console.print(f" {p['id']:<20} {p['doc_count']:>5} docs "
860
+ f"{p['size'] / 1e6:>9.1f} MB {p['sha256'][:16]}…")
861
+
862
+
863
+ @packs_app.command("serve")
864
+ def packs_serve_cmd(out: Path = typer.Option(
865
+ Path("export-packs"), "--dir", exists=True, file_okay=False,
866
+ help="dir with .rfcpack files + catalog.json"),
867
+ host: str = typer.Option("0.0.0.0", help="bind address"),
868
+ port: int = typer.Option(8100, help="TCP port")):
869
+ """Serve packs over LAN HTTP for the Android reader (Sync feature)."""
870
+ httpd = pack_serve.serve(out, host, port)
871
+ console.print(f"serving [bold]{out}[/bold] on "
872
+ f"[cyan]http://{host or '0.0.0.0'}:{port}[/cyan] "
873
+ "(Ctrl-C to stop)")
874
+ try:
875
+ httpd.serve_forever()
876
+ except KeyboardInterrupt:
877
+ console.print("stopped")
878
+
879
+
880
+ def main(argv=None):
881
+ import sys
882
+ try:
883
+ # Windows consoles default to a legacy code page: '§' and '¶' in
884
+ # refs would print as '?'. MCP stdio is JSON-RPC in UTF-8 either way.
885
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
886
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
887
+ except Exception:
888
+ pass
889
+ try:
890
+ if argv is None:
891
+ app()
892
+ else:
893
+ app(args=list(argv), prog_name="primarysource rfc")
894
+ except LibError as e:
895
+ # the same error:/hint:/action: lines an MCP client gets (PLAN.md
896
+ # §5.10); str(e) alone would drop the remedy
897
+ print(e.as_text(), file=sys.stderr)
898
+ sys.exit(1)
899
+
900
+
901
+ if __name__ == "__main__":
902
+ main()
903
+