awkno 0.2.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 (91) hide show
  1. awkno/__init__.py +25 -0
  2. awkno/_doctor.py +114 -0
  3. awkno/cli.py +497 -0
  4. awkno/corpus.py +216 -0
  5. awkno/generate.py +444 -0
  6. awkno/pages/agent-senses.json +13 -0
  7. awkno/pages/agent-vm.json +14 -0
  8. awkno/pages/aitherconnect.json +11 -0
  9. awkno/pages/aitherkvcache.json +11 -0
  10. awkno/pages/aitherzero.json +11 -0
  11. awkno/pages/awarena.json +14 -0
  12. awkno/pages/awask.json +15 -0
  13. awkno/pages/awbac.json +11 -0
  14. awkno/pages/awbrowse.json +13 -0
  15. awkno/pages/awdit.json +12 -0
  16. awkno/pages/awdk.json +16 -0
  17. awkno/pages/awevolve.json +15 -0
  18. awkno/pages/awfind.json +13 -0
  19. awkno/pages/awgit.json +12 -0
  20. awkno/pages/awgraph.json +12 -0
  21. awkno/pages/awiam.json +12 -0
  22. awkno/pages/awkit.json +13 -0
  23. awkno/pages/awkno.json +13 -0
  24. awkno/pages/awknowledge.json +12 -0
  25. awkno/pages/awm.json +13 -0
  26. awkno/pages/awmail.json +18 -0
  27. awkno/pages/awnboard.json +20 -0
  28. awkno/pages/awnest.json +19 -0
  29. awkno/pages/awnet.json +13 -0
  30. awkno/pages/awnix.json +20 -0
  31. awkno/pages/awnode.json +12 -0
  32. awkno/pages/awpack.json +13 -0
  33. awkno/pages/awpredict.json +11 -0
  34. awkno/pages/awprism.json +14 -0
  35. awkno/pages/awreason.json +14 -0
  36. awkno/pages/awrecover.json +13 -0
  37. awkno/pages/awrecurse.json +13 -0
  38. awkno/pages/awrelay.json +12 -0
  39. awkno/pages/awrepl.json +13 -0
  40. awkno/pages/awresearch.json +14 -0
  41. awkno/pages/awrun.json +13 -0
  42. awkno/pages/awseal.json +12 -0
  43. awkno/pages/awsh.json +13 -0
  44. awkno/pages/awshare.json +12 -0
  45. awkno/pages/awskills.json +12 -0
  46. awkno/pages/awsync.json +16 -0
  47. awkno/pages/awtunnel.json +12 -0
  48. awkno/pages/cited-research.json +14 -0
  49. awkno/pages/gobbonet-agentic.json +14 -0
  50. awkno/pages/guide-00.json +16 -0
  51. awkno/pages/guide-01.json +14 -0
  52. awkno/pages/guide-02.json +14 -0
  53. awkno/pages/guide-03.json +14 -0
  54. awkno/pages/guide-04.json +15 -0
  55. awkno/pages/guide-05.json +16 -0
  56. awkno/pages/guide-06.json +15 -0
  57. awkno/pages/guide-07.json +15 -0
  58. awkno/pages/guide-08.json +17 -0
  59. awkno/pages/guide-09.json +13 -0
  60. awkno/pages/guide.json +22 -0
  61. awkno/pages/law-01.json +11 -0
  62. awkno/pages/law-02.json +11 -0
  63. awkno/pages/law-03.json +11 -0
  64. awkno/pages/law-04.json +11 -0
  65. awkno/pages/law-05.json +11 -0
  66. awkno/pages/law-06.json +11 -0
  67. awkno/pages/law-07.json +11 -0
  68. awkno/pages/law-08.json +11 -0
  69. awkno/pages/law-09.json +11 -0
  70. awkno/pages/law-10.json +11 -0
  71. awkno/pages/law-11.json +11 -0
  72. awkno/pages/law-12.json +11 -0
  73. awkno/pages/law-13.json +11 -0
  74. awkno/pages/law-14.json +11 -0
  75. awkno/pages/law-15.json +11 -0
  76. awkno/pages/law-16.json +11 -0
  77. awkno/pages/law-17.json +11 -0
  78. awkno/pages/law-18.json +11 -0
  79. awkno/pages/law-19.json +11 -0
  80. awkno/pages/one-surface.json +15 -0
  81. awkno/pages/provenance.json +14 -0
  82. awkno/pages/shared-worktree.json +14 -0
  83. awkno/pages/the-front-door.json +15 -0
  84. awkno/pages/the-reasoning-loop.json +14 -0
  85. awkno/pages/who-what-did.json +13 -0
  86. awkno-0.2.0.dist-info/METADATA +321 -0
  87. awkno-0.2.0.dist-info/RECORD +91 -0
  88. awkno-0.2.0.dist-info/WHEEL +5 -0
  89. awkno-0.2.0.dist-info/entry_points.txt +2 -0
  90. awkno-0.2.0.dist-info/licenses/LICENSE +118 -0
  91. awkno-0.2.0.dist-info/top_level.txt +1 -0
awkno/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """awkno — The man page for the Aither World.
2
+
3
+ from awkno import AwknoRegistry
4
+
5
+ registry = AwknoRegistry()
6
+ page = registry.get("awdk")
7
+ print(page.render())
8
+
9
+ Every brick, stack and law in your terminal, offline. No external dependencies,
10
+ no network required. Pages are generated from ecosystem.yaml and the laws corpus,
11
+ committed as data files.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from awkno.corpus import AwknoPage, AwknoRegistry, NotFoundError
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "AwknoRegistry",
22
+ "AwknoPage",
23
+ "NotFoundError",
24
+ "__version__",
25
+ ]
awkno/_doctor.py ADDED
@@ -0,0 +1,114 @@
1
+ """Stack-aware `doctor` for awkno.
2
+
3
+ GENERATED BY gen_aw_doctor.py -- DO NOT EDIT.
4
+ Regenerate it with the generator named above; a hand-edit here is reverted by
5
+ the next run and fails the parity gate.
6
+
7
+ Why a doctor exists at all: the aw* bricks are designed to COMPOSE, so the
8
+ interesting failures live BETWEEN them. "awkno is installed" is not the useful
9
+ fact -- "awkno is installed and the thing it pairs with is not" is. This reports
10
+ the whole stack, not just itself.
11
+
12
+ stdlib only, on purpose: a diagnostic that cannot run because a dependency is
13
+ missing is worthless precisely when you need it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import shutil
19
+ import sys
20
+
21
+ #: Frozen from the aw* registry at generation time. A shipped
22
+ #: package cannot read the registry, and a doctor that guessed at the family
23
+ #: would go stale in silence. Regenerate to update.
24
+ SELF = 'awkno'
25
+ FAMILY = ['awbac', 'awbrowse', 'awdit', 'awevolve', 'awfind', 'awgit', 'awgraph', 'awiam', 'awm', 'awmail', 'awnboard', 'awnest', 'awnet', 'awprism', 'awreason', 'awrecover', 'awrecurse', 'awrelay', 'awrepl', 'awresearch', 'awrun', 'awseal', 'awshare', 'awtunnel']
26
+ PAIRS_WITH = ['awfind', 'awm']
27
+
28
+
29
+ def _installed(mod: str) -> "str | None":
30
+ """Version if importable, else None. Never raises -- a broken sibling must
31
+ not take the diagnostic down with it."""
32
+ try:
33
+ if importlib.util.find_spec(mod) is None:
34
+ return None
35
+ except (ImportError, ValueError):
36
+ return None
37
+ try:
38
+ from importlib.metadata import PackageNotFoundError, version
39
+ try:
40
+ return version(mod)
41
+ except PackageNotFoundError:
42
+ return "installed"
43
+ except Exception:
44
+ return "installed"
45
+
46
+
47
+ def report(out=None) -> int:
48
+ """Print the stack picture. 0 = this brick and its pairs are present."""
49
+ out = out or sys.stdout
50
+ print(f"{SELF} doctor", file=out)
51
+
52
+ mine = _installed(SELF)
53
+ print(f" self {SELF} {mine or 'NOT IMPORTABLE'}", file=out)
54
+ shim = shutil.which(SELF)
55
+ print(f" command {shim or 'not on PATH'}", file=out)
56
+
57
+ # The stack. Siblings this brick pairs with are called out separately,
58
+ # because a missing pair is a REASON, while a missing unrelated brick is
59
+ # just a fact about your machine.
60
+ missing_pairs, present = [], []
61
+ for name in FAMILY:
62
+ v = _installed(name)
63
+ if v:
64
+ present.append(name)
65
+ elif name in PAIRS_WITH:
66
+ missing_pairs.append(name)
67
+ print(f" stack {len(present)}/{len(FAMILY)} aw* packages installed",
68
+ file=out)
69
+ if present:
70
+ print(f" {' '.join(sorted(present))}", file=out)
71
+
72
+ local = _local_checks()
73
+ for line in local:
74
+ print(f" {line}", file=out)
75
+
76
+ if mine is None:
77
+ print(f"\nverdict: {SELF} itself is not importable. Reinstall it before "
78
+ f"anything else here means much.", file=out)
79
+ return 1
80
+ if missing_pairs:
81
+ print(f"\nverdict: {SELF} works, but pairs with "
82
+ f"{', '.join(sorted(missing_pairs))} which "
83
+ f"{'is' if len(missing_pairs) == 1 else 'are'} not installed. "
84
+ f"That is a capability you are missing, not an error.", file=out)
85
+ return 0
86
+ print(f"\nverdict: {SELF} and everything it pairs with are present.", file=out)
87
+ return 0
88
+
89
+
90
+ def _local_checks() -> "list[str]":
91
+ """Per-brick checks, if this package defines them.
92
+
93
+ Kept as a HOOK rather than generated guesses: the generator knows the family
94
+ from the registry, but it does not know what awkno needs at runtime, and a
95
+ doctor that invented config requirements would be confidently wrong. A
96
+ package supplies `_doctor_local()` returning display lines; absent, the
97
+ stack picture above still stands on its own.
98
+ """
99
+ try:
100
+ mod = importlib.import_module(f"{SELF}.doctor_local")
101
+ except Exception:
102
+ return []
103
+ try:
104
+ return list(mod._doctor_local())
105
+ except Exception as exc: # noqa: BLE001
106
+ return [f"local checks raised {type(exc).__name__}: {exc}"]
107
+
108
+
109
+ def main(argv: "list[str] | None" = None) -> int:
110
+ return report()
111
+
112
+
113
+ if __name__ == "__main__":
114
+ raise SystemExit(main())
awkno/cli.py ADDED
@@ -0,0 +1,497 @@
1
+ """awkno CLI.
2
+
3
+ awkno awdk
4
+ awkno awsh
5
+ awkno law 5
6
+ awkno list
7
+ awkno search design-for-the-silence
8
+ awkno -k silence
9
+ awkno --plain
10
+
11
+ Type awkno with no args to show an overview.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+
22
+ from awkno.corpus import AwknoRegistry, NotFoundError
23
+
24
+
25
+ def pager_render(text: str) -> None:
26
+ """Render text through $PAGER if available, else stdout."""
27
+ pager = os.environ.get("PAGER", "less" if sys.platform != "win32" else "more")
28
+
29
+ if not sys.stdout.isatty():
30
+ print(text)
31
+ return
32
+
33
+ try:
34
+ import subprocess
35
+
36
+ proc = subprocess.Popen(
37
+ pager, stdin=subprocess.PIPE, text=True, bufsize=1024
38
+ )
39
+ import contextlib
40
+ with contextlib.suppress(BrokenPipeError):
41
+ # the reader quit the pager mid-stream — that is the pager working
42
+ proc.stdin.write(text)
43
+ proc.stdin.close()
44
+ proc.wait()
45
+ except (FileNotFoundError, OSError):
46
+ print(text)
47
+
48
+
49
+ def show_overview() -> None:
50
+ """Show the overview page."""
51
+ text = """NAME
52
+ awkno — The man page for the Aither World
53
+
54
+ SYNOPSIS
55
+ awkno [TOPIC]
56
+ awkno list
57
+ awkno law <N|SLUG>
58
+ awkno guide [N]
59
+ awkno open [TOPIC]
60
+ awkno search TERM
61
+ awkno -k TERM
62
+ awkno --plain
63
+ awkno --json
64
+
65
+ DESCRIPTION
66
+ awkno is an offline reference for the Aither World ecosystem. Every brick
67
+ (standalone tool), stack (curated set), law (learned principle) and every
68
+ chapter of the Aither World Guide lives here — no browser, no internet
69
+ connection needed. `awkno open` renders any page to a local HTML file and
70
+ opens it in your browser, still offline.
71
+
72
+ The registry is built from ecosystem.yaml and the laws corpus at build time
73
+ and committed as data files. After install, the pages are always there.
74
+
75
+ QUICK START
76
+ awkno awdk Show the awdk brick
77
+ awkno awsh Show the awsh brick
78
+ awkno law 5 Show law 5
79
+ awkno guide The Aither World Guide: the chapters, in order
80
+ awkno guide 2 Chapter 2 (your first local brain), in the pager
81
+ awkno open guide 2 The same chapter, in your web browser, offline
82
+ awkno list List all topics
83
+ awkno search kubernetes Search for "kubernetes"
84
+
85
+ OPTIONS
86
+ --plain No ANSI formatting
87
+ --json JSON output (for piping)
88
+ -k, --apropos TERM Search for TERM (like man -k)
89
+
90
+ SEE ALSO
91
+ For the full ecosystem, visit https://github.com/Aitherium/awkno
92
+ """
93
+ pager_render(text)
94
+
95
+
96
+ def slugify(text: str) -> str:
97
+ """Reduce a synopsis to a comparable slug."""
98
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
99
+
100
+
101
+ def resolve_law_key(registry: "AwknoRegistry", law_id: str) -> str | None:
102
+ """Map `5` or `design-for-the-silence` onto a law page key.
103
+
104
+ A slug matches when it prefixes the slugified synopsis of exactly ONE law.
105
+ Ambiguous prefixes return None rather than the first hit: a prefix matcher
106
+ that guesses is how `get("nemotron")` came to answer for a model nobody
107
+ registered.
108
+ """
109
+ if law_id.isdigit():
110
+ return f"law-{int(law_id):02d}"
111
+
112
+ want = slugify(law_id)
113
+ if not want:
114
+ return None
115
+
116
+ laws = [t for t in registry.list_topics() if t.startswith("law-")]
117
+
118
+ # The law's OWN slug first. Exact, and it survives a retitle -- the
119
+ # synopsis fallback below does not.
120
+ for topic in laws:
121
+ if slugify(registry.pages[topic].slug or "") == want:
122
+ return topic
123
+
124
+ # Fall back to a unique prefix of the synopsis, for a corpus generated
125
+ # before slugs were carried. Unique or nothing: a prefix matcher that
126
+ # answers with its first hit is fail-open, and returns a confident wrong
127
+ # law for a slug nobody registered.
128
+ hits = [
129
+ topic
130
+ for topic in laws
131
+ if slugify(registry.pages[topic].synopsis).startswith(want)
132
+ ]
133
+ return hits[0] if len(hits) == 1 else None
134
+
135
+
136
+ def resolve_topic_key(registry: "AwknoRegistry", text: str) -> str | None:
137
+ """`guide` / `guide 2` / `law 5` / `awdk` -> the corpus key, or None."""
138
+ parts = text.strip().split()
139
+ if not parts:
140
+ return None
141
+ head = parts[0].lower()
142
+ if head == "guide":
143
+ if len(parts) == 1:
144
+ return "guide" if "guide" in registry.pages else None
145
+ n = parts[1]
146
+ if n.isdigit():
147
+ key = f"guide-{int(n):02d}"
148
+ return key if key in registry.pages else None
149
+ # a chapter slug (02-first-brain / first-brain)
150
+ for key, page in registry.pages.items():
151
+ if page.category == "guide" and page.slug and (
152
+ page.slug == n or page.slug.endswith("-" + n)):
153
+ return key
154
+ return None
155
+ if head == "law" and len(parts) > 1:
156
+ return resolve_law_key(registry, parts[1])
157
+ key = text.strip().lower()
158
+ return key if key in registry.pages else None
159
+
160
+
161
+ _HTML = """<!doctype html>
162
+ <html lang="en"><head><meta charset="utf-8">
163
+ <meta name="viewport" content="width=device-width,initial-scale=1">
164
+ <title>{title} - awkno</title>
165
+ <style>
166
+ body{{margin:0;background:#0a0f1e;color:#e6ecf5;font:16px/1.6 Inter,system-ui,sans-serif}}
167
+ main{{max-width:880px;margin:0 auto;padding:2rem 1.25rem 4rem}}
168
+ h1{{font-size:1.8rem;margin:.2rem 0 .4rem}}
169
+ .syn{{opacity:.8;margin:0 0 1.5rem;white-space:pre-wrap}}
170
+ pre{{white-space:pre-wrap;background:#111a33;border:1px solid #22305a;
171
+ border-radius:10px;padding:1rem;line-height:1.5}}
172
+ nav a{{color:#7fdbea;margin-right:1rem}}
173
+ .k{{font:13px JetBrains Mono,monospace;letter-spacing:.08em;text-transform:uppercase;opacity:.6}}
174
+ footer{{opacity:.6;font-size:.85rem;margin-top:2rem}}
175
+ </style></head><body><main>
176
+ <nav>{nav}</nav>
177
+ <p class="k">{category} &middot; awkno, offline</p>
178
+ <h1>{title}</h1>
179
+ <p class="syn">{synopsis}</p>
180
+ <pre>{body}</pre>
181
+ <footer>Rendered by <code>awkno open</code> from the committed corpus. The same page online:
182
+ <a href="{online}">{online}</a></footer>
183
+ </main></body></html>
184
+ """
185
+
186
+
187
+ def write_html(registry: "AwknoRegistry", key: str):
188
+ """Render one page to ~/.aither/awkno/<key>.html and return the path."""
189
+ import html as _html
190
+ from pathlib import Path
191
+
192
+ page = registry.get(key)
193
+ out_dir = Path.home() / ".aither" / "awkno"
194
+ out_dir.mkdir(parents=True, exist_ok=True)
195
+ nav = []
196
+ if page.category == "guide":
197
+ nav.append('<a href="guide.html">The Guide</a>')
198
+ for sib in page.see_also or []:
199
+ if sib.startswith("guide-"):
200
+ nav.append(f'<a href="{sib}.html">next: {sib}</a>')
201
+ if page.category == "guide":
202
+ online = "https://aitherium.github.io/awknowledge/" + (
203
+ f"path/{page.slug}.html" if page.slug else "")
204
+ else:
205
+ online = "https://aitherium.github.io/awknowledge/man/" + (
206
+ f"{page.topic}.html" if page.category == "brick" else "index.html")
207
+ body = page.render(plain=True)
208
+ text = _HTML.format(
209
+ title=_html.escape(page.synopsis or page.topic),
210
+ synopsis=_html.escape(page.description or ""),
211
+ category=_html.escape(page.category),
212
+ body=_html.escape(body),
213
+ nav=" ".join(nav),
214
+ online=online,
215
+ )
216
+ path = out_dir / f"{key}.html"
217
+ path.write_text(text, encoding="utf-8")
218
+ # Sibling pages the nav links to, so "next" works offline too.
219
+ if page.category == "guide":
220
+ for sib in page.see_also or []:
221
+ if sib.startswith("guide-") and not (out_dir / f"{sib}.html").exists():
222
+ write_html(registry, sib)
223
+ if key != "guide" and not (out_dir / "guide.html").exists():
224
+ write_html(registry, "guide")
225
+ return path
226
+
227
+
228
+ def _build_parser() -> argparse.ArgumentParser:
229
+ """Build the CLI parser.
230
+
231
+ Separate from main() so the self-test can assert that every form printed
232
+ in the SYNOPSIS actually parses.
233
+ """
234
+ parser = argparse.ArgumentParser(
235
+ description="The man page for the Aither World",
236
+ add_help=False,
237
+ usage="awkno [TOPIC] or awkno COMMAND [OPTIONS]",
238
+ )
239
+
240
+ # nargs="*", not "?": the SYNOPSIS advertises `awkno law 5` and
241
+ # `awkno search TERM`, both of which argparse rejected as
242
+ # "unrecognized arguments" under nargs="?" -- only the quoted
243
+ # `awkno "law 5"` worked, which nobody would type.
244
+ parser.add_argument(
245
+ "topic", nargs="*", help="Topic, brick, stack, law, or command"
246
+ )
247
+ parser.add_argument(
248
+ "--plain", action="store_true", help="Plain text, no ANSI formatting"
249
+ )
250
+ parser.add_argument(
251
+ "--json", action="store_true", help="JSON output for piping"
252
+ )
253
+ parser.add_argument(
254
+ "-k", "--apropos", dest="search", help="Search for term (like man -k)"
255
+ )
256
+ parser.add_argument(
257
+ "--self-test", action="store_true", help="Run self-test"
258
+ )
259
+
260
+ return parser
261
+
262
+
263
+ def main() -> None:
264
+ """Main CLI entry point."""
265
+ # GENERATED doctor intercept (gen_aw_doctor.py) -- do not edit
266
+ _dv = locals().get("argv")
267
+ if (_dv if _dv is not None else __import__("sys").argv[1:])[:1] == ["doctor"]:
268
+ from ._doctor import report
269
+ return report()
270
+ parser = _build_parser()
271
+ args = parser.parse_args()
272
+ topic = " ".join(args.topic).strip()
273
+
274
+ if args.self_test:
275
+ _self_test()
276
+ return
277
+
278
+ registry = AwknoRegistry()
279
+
280
+ if not topic and not args.search:
281
+ show_overview()
282
+ return
283
+
284
+ if args.search:
285
+ results = registry.search(args.search)
286
+ if not results:
287
+ print(f"No matches for '{args.search}'")
288
+ return
289
+ if args.json:
290
+ output = [
291
+ {"topic": page.topic, "score": score}
292
+ for page, score in results[:10]
293
+ ]
294
+ print(json.dumps(output))
295
+ else:
296
+ print(f"\n[{args.search}]\n")
297
+ for page, score in results[:10]:
298
+ print(f" {page.topic:<20} {page.synopsis}")
299
+ return
300
+
301
+ topic_lower = topic.lower()
302
+
303
+ if topic_lower == "list":
304
+ topics = registry.list_topics()
305
+ categories = {
306
+ "guide": [],
307
+ "brick": [],
308
+ "stack": [],
309
+ "law": [],
310
+ "topic": [],
311
+ }
312
+ for t in topics:
313
+ page = registry.pages[t]
314
+ if page.category not in categories:
315
+ categories[page.category] = []
316
+ categories[page.category].append(t)
317
+
318
+ print()
319
+ for cat in ["guide", "brick", "stack", "law", "topic"]:
320
+ if categories[cat]:
321
+ print(f"{cat.upper()}S ({len(categories[cat])})")
322
+ for name in sorted(categories[cat]):
323
+ page = registry.pages[name]
324
+ print(f" {name:<20} {page.synopsis}")
325
+ print()
326
+ return
327
+
328
+ # `awkno open [TOPIC]` -- the same page, rendered to a local HTML file and
329
+ # opened in the default browser. Offline: nothing is fetched.
330
+ if topic_lower == "open" or topic_lower.startswith("open "):
331
+ rest = topic[4:].strip() or "guide"
332
+ key = resolve_topic_key(registry, rest)
333
+ if key is None:
334
+ print(f"Topic '{rest}' not found. Try: awkno open guide, awkno open guide 2")
335
+ return
336
+ path = write_html(registry, key)
337
+ print(f"wrote {path}")
338
+ import webbrowser
339
+
340
+ if not webbrowser.open(path.as_uri()):
341
+ print("(could not launch a browser - open the file above by hand)")
342
+ return
343
+
344
+ if topic_lower == "guide" or topic_lower.startswith("guide "):
345
+ key = resolve_topic_key(registry, topic)
346
+ if key is None:
347
+ print("Usage: awkno guide [N] (N = chapter number, e.g. awkno guide 2)")
348
+ return
349
+ page = registry.get(key)
350
+ if args.json:
351
+ print(json.dumps(page.to_dict()))
352
+ else:
353
+ pager_render(page.render(plain=args.plain))
354
+ return
355
+
356
+ if topic_lower.startswith("law"):
357
+ parts = topic.split()
358
+ if len(parts) < 2:
359
+ print("Usage: awkno law <N|SLUG>")
360
+ return
361
+ law_id = parts[1]
362
+ law_key = resolve_law_key(registry, law_id)
363
+ if law_key is None:
364
+ print(
365
+ f"Law '{law_id}' not found or ambiguous. "
366
+ "Try: awkno law 5, awkno law design-for-the-silence"
367
+ )
368
+ return
369
+ try:
370
+ page = registry.get(law_key)
371
+ except NotFoundError:
372
+ print(f"Law '{law_id}' not found. Try: awkno law 1, awkno law 5, etc.")
373
+ return
374
+ else:
375
+ try:
376
+ page = registry.get(topic)
377
+ except NotFoundError:
378
+ print(f"Topic '{topic}' not found")
379
+ print(f"Try: awkno list, awkno -k '{topic}'")
380
+ return
381
+
382
+ if args.json:
383
+ print(json.dumps(page.to_dict()))
384
+ else:
385
+ text = page.render(plain=args.plain)
386
+ pager_render(text)
387
+
388
+
389
+ def _self_test() -> None:
390
+ """Run self-test (pure, no external service needed)."""
391
+ from awkno.corpus import AwknoPage, AwknoRegistry
392
+
393
+ page = AwknoPage(
394
+ topic="test",
395
+ category="topic",
396
+ synopsis="A test page",
397
+ description="This is a test",
398
+ adopt="Adopt this test",
399
+ status="test",
400
+ )
401
+
402
+ assert page.topic == "test"
403
+ assert page.render()
404
+ assert page.to_dict()
405
+
406
+ # --- the SYNOPSIS is a promise; assert it ------------------------------
407
+ #
408
+ # This block exists because every two-word form printed in this tool's own
409
+ # SYNOPSIS -- `awkno law 5`, `awkno search TERM` -- was rejected by argparse
410
+ # as "unrecognized arguments" in a SHIPPED release on PyPI, and `<N|SLUG>`
411
+ # advertised a slug lookup the generator computed and discarded, so no slug
412
+ # ever resolved. The self-test above could not see any of it: it asserted
413
+ # that a dataclass renders. A tool that documents an invocation it cannot
414
+ # perform is a broken tool that reads as an authoritative one.
415
+ parser = _build_parser()
416
+
417
+ for argv in (["law", "5"], ["search", "silence"], ["list"], ["awdk"],
418
+ ["guide"], ["guide", "2"], ["open", "guide", "2"]):
419
+ try:
420
+ parsed = parser.parse_args(argv)
421
+ except SystemExit: # argparse exits rather than raising
422
+ raise AssertionError(
423
+ f"SYNOPSIS form `awkno {' '.join(argv)}` was REJECTED by the parser"
424
+ ) from None
425
+ joined = " ".join(parsed.topic).strip()
426
+ assert joined == " ".join(argv), f"SYNOPSIS form {argv} did not parse"
427
+
428
+ # the quoted form callers may already be using must keep working
429
+ assert " ".join(parser.parse_args(["law 5"]).topic).strip() == "law 5"
430
+
431
+ # --- the guide is in the corpus and resolvable every way the SYNOPSIS says
432
+ reg = AwknoRegistry()
433
+ assert "guide" in reg.pages, "corpus has no `guide` index page - regenerate"
434
+ assert resolve_topic_key(reg, "guide") == "guide"
435
+ assert resolve_topic_key(reg, "guide 2") == "guide-02", "guide N must resolve"
436
+ assert resolve_topic_key(reg, "guide first-brain") == "guide-02", "slug must resolve"
437
+ assert resolve_topic_key(reg, "guide 99") is None
438
+ chapters = reg.list_by_category("guide")
439
+ assert len(chapters) >= 10, f"expected the full journey, got {len(chapters)}"
440
+ two = reg.get("guide-02")
441
+ assert "adk quickstart-local" in (two.body or ""), "chapter 2 lost its DO steps"
442
+ assert "D-" not in (two.body or ""), "internal ref leaked into the guide corpus"
443
+
444
+ registry = AwknoRegistry()
445
+
446
+ # numeric and slug lookups both resolve, and to the SAME law
447
+ by_num = resolve_law_key(registry, "5")
448
+ by_slug = resolve_law_key(registry, "design-for-the-silence")
449
+ assert by_num == "law-05", f"numeric law lookup gave {by_num}"
450
+ assert by_slug == "law-05", f"slug law lookup gave {by_slug}"
451
+ assert registry.get(by_num).synopsis
452
+
453
+ # every law is reachable by BOTH spellings -- a resolver that answers for
454
+ # law 5 and nothing else passes a single-case test while being useless
455
+ law_topics = [t for t in registry.list_topics() if t.startswith("law-")]
456
+ assert len(law_topics) >= 19, f"only {len(law_topics)} laws in the corpus"
457
+ for topic in law_topics:
458
+ number = topic.split("-", 1)[1].lstrip("0") or "0"
459
+ assert resolve_law_key(registry, number) == topic, f"{topic} not reachable by number"
460
+ slug = slugify(registry.pages[topic].synopsis)
461
+ assert resolve_law_key(registry, slug) == topic, f"{topic} not reachable by slug"
462
+ own = registry.pages[topic].slug
463
+ assert own, f"{topic} carries no slug -- regenerate the corpus"
464
+ assert resolve_law_key(registry, own) == topic, f"{topic} not reachable by its own slug"
465
+
466
+ # A RETITLED law: slug and synopsis diverge, which is the only case the
467
+ # exact-slug branch exists for. Without this the branch is dead weight --
468
+ # every current law's filename slug happens to prefix its own synopsis, so
469
+ # deleting the branch leaves the suite green and the lookup silently
470
+ # dependent on a coincidence that a single retitle ends.
471
+ registry.pages["law-99"] = AwknoPage(
472
+ topic="law-99",
473
+ category="law",
474
+ synopsis="Completely different words after a retitle",
475
+ description="Law #99",
476
+ slug="the-original-filename-slug",
477
+ )
478
+ try:
479
+ assert resolve_law_key(registry, "the-original-filename-slug") == "law-99", (
480
+ "a retitled law is unreachable by its own slug"
481
+ )
482
+ assert resolve_law_key(registry, "completely-different-words") == "law-99"
483
+ finally:
484
+ del registry.pages["law-99"]
485
+
486
+ # fail closed: nonsense and ambiguity resolve to nothing, never to law 1.
487
+ # An ambiguous prefix returning its first hit is the trap that made a
488
+ # licence lookup answer for models nobody had registered.
489
+ assert resolve_law_key(registry, "no-such-law-anywhere") is None
490
+ assert resolve_law_key(registry, "") is None
491
+ assert resolve_law_key(registry, "a") is None, "bare prefix must be ambiguous, not law-01"
492
+
493
+ print(f"[OK] awkno self-test passed ({len(law_topics)} laws, both spellings)")
494
+
495
+
496
+ if __name__ == "__main__":
497
+ main()