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.
- awkno/__init__.py +25 -0
- awkno/_doctor.py +114 -0
- awkno/cli.py +497 -0
- awkno/corpus.py +216 -0
- awkno/generate.py +444 -0
- awkno/pages/agent-senses.json +13 -0
- awkno/pages/agent-vm.json +14 -0
- awkno/pages/aitherconnect.json +11 -0
- awkno/pages/aitherkvcache.json +11 -0
- awkno/pages/aitherzero.json +11 -0
- awkno/pages/awarena.json +14 -0
- awkno/pages/awask.json +15 -0
- awkno/pages/awbac.json +11 -0
- awkno/pages/awbrowse.json +13 -0
- awkno/pages/awdit.json +12 -0
- awkno/pages/awdk.json +16 -0
- awkno/pages/awevolve.json +15 -0
- awkno/pages/awfind.json +13 -0
- awkno/pages/awgit.json +12 -0
- awkno/pages/awgraph.json +12 -0
- awkno/pages/awiam.json +12 -0
- awkno/pages/awkit.json +13 -0
- awkno/pages/awkno.json +13 -0
- awkno/pages/awknowledge.json +12 -0
- awkno/pages/awm.json +13 -0
- awkno/pages/awmail.json +18 -0
- awkno/pages/awnboard.json +20 -0
- awkno/pages/awnest.json +19 -0
- awkno/pages/awnet.json +13 -0
- awkno/pages/awnix.json +20 -0
- awkno/pages/awnode.json +12 -0
- awkno/pages/awpack.json +13 -0
- awkno/pages/awpredict.json +11 -0
- awkno/pages/awprism.json +14 -0
- awkno/pages/awreason.json +14 -0
- awkno/pages/awrecover.json +13 -0
- awkno/pages/awrecurse.json +13 -0
- awkno/pages/awrelay.json +12 -0
- awkno/pages/awrepl.json +13 -0
- awkno/pages/awresearch.json +14 -0
- awkno/pages/awrun.json +13 -0
- awkno/pages/awseal.json +12 -0
- awkno/pages/awsh.json +13 -0
- awkno/pages/awshare.json +12 -0
- awkno/pages/awskills.json +12 -0
- awkno/pages/awsync.json +16 -0
- awkno/pages/awtunnel.json +12 -0
- awkno/pages/cited-research.json +14 -0
- awkno/pages/gobbonet-agentic.json +14 -0
- awkno/pages/guide-00.json +16 -0
- awkno/pages/guide-01.json +14 -0
- awkno/pages/guide-02.json +14 -0
- awkno/pages/guide-03.json +14 -0
- awkno/pages/guide-04.json +15 -0
- awkno/pages/guide-05.json +16 -0
- awkno/pages/guide-06.json +15 -0
- awkno/pages/guide-07.json +15 -0
- awkno/pages/guide-08.json +17 -0
- awkno/pages/guide-09.json +13 -0
- awkno/pages/guide.json +22 -0
- awkno/pages/law-01.json +11 -0
- awkno/pages/law-02.json +11 -0
- awkno/pages/law-03.json +11 -0
- awkno/pages/law-04.json +11 -0
- awkno/pages/law-05.json +11 -0
- awkno/pages/law-06.json +11 -0
- awkno/pages/law-07.json +11 -0
- awkno/pages/law-08.json +11 -0
- awkno/pages/law-09.json +11 -0
- awkno/pages/law-10.json +11 -0
- awkno/pages/law-11.json +11 -0
- awkno/pages/law-12.json +11 -0
- awkno/pages/law-13.json +11 -0
- awkno/pages/law-14.json +11 -0
- awkno/pages/law-15.json +11 -0
- awkno/pages/law-16.json +11 -0
- awkno/pages/law-17.json +11 -0
- awkno/pages/law-18.json +11 -0
- awkno/pages/law-19.json +11 -0
- awkno/pages/one-surface.json +15 -0
- awkno/pages/provenance.json +14 -0
- awkno/pages/shared-worktree.json +14 -0
- awkno/pages/the-front-door.json +15 -0
- awkno/pages/the-reasoning-loop.json +14 -0
- awkno/pages/who-what-did.json +13 -0
- awkno-0.2.0.dist-info/METADATA +321 -0
- awkno-0.2.0.dist-info/RECORD +91 -0
- awkno-0.2.0.dist-info/WHEEL +5 -0
- awkno-0.2.0.dist-info/entry_points.txt +2 -0
- awkno-0.2.0.dist-info/licenses/LICENSE +118 -0
- 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} · 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()
|