lorewiki 0.2.1__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.
- lorewiki/__init__.py +4 -0
- lorewiki/__main__.py +6 -0
- lorewiki/cli/__init__.py +25 -0
- lorewiki/cli/add.py +324 -0
- lorewiki/cli/apps.py +181 -0
- lorewiki/cli/commands.py +659 -0
- lorewiki/cli/config_cmds.py +92 -0
- lorewiki/cli/helpers.py +177 -0
- lorewiki/cli/topic_cmds.py +350 -0
- lorewiki/config.py +265 -0
- lorewiki/db/__init__.py +25 -0
- lorewiki/db/connection.py +125 -0
- lorewiki/db/models.py +57 -0
- lorewiki/db/schema.sql +104 -0
- lorewiki/indexer/__init__.py +20 -0
- lorewiki/indexer/chunker.py +229 -0
- lorewiki/indexer/cleaning.py +402 -0
- lorewiki/indexer/indexer.py +275 -0
- lorewiki/indexer/parser.py +113 -0
- lorewiki/llm/__init__.py +24 -0
- lorewiki/llm/client.py +290 -0
- lorewiki/llm/generator.py +203 -0
- lorewiki/py.typed +0 -0
- lorewiki/retriever/__init__.py +18 -0
- lorewiki/retriever/base.py +21 -0
- lorewiki/retriever/bm25.py +226 -0
- lorewiki/retriever/fusion.py +88 -0
- lorewiki/retriever/hierarchy.py +248 -0
- lorewiki/retriever/search.py +89 -0
- lorewiki/retriever/vector.py +51 -0
- lorewiki/topic.py +675 -0
- lorewiki/utils/__init__.py +5 -0
- lorewiki/utils/logger.py +81 -0
- lorewiki/utils/topic_shared.py +39 -0
- lorewiki-0.2.1.dist-info/METADATA +363 -0
- lorewiki-0.2.1.dist-info/RECORD +39 -0
- lorewiki-0.2.1.dist-info/WHEEL +4 -0
- lorewiki-0.2.1.dist-info/entry_points.txt +2 -0
- lorewiki-0.2.1.dist-info/licenses/LICENSE +21 -0
lorewiki/__init__.py
ADDED
lorewiki/__main__.py
ADDED
lorewiki/cli/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Top-level CLI package.
|
|
2
|
+
|
|
3
|
+
Importing this package side-effect-imports every command module so
|
|
4
|
+
that all ``@app.command()`` / ``@config_app.command()`` /
|
|
5
|
+
``@topic_app.command()`` decorators fire. The single ``app`` symbol
|
|
6
|
+
exposed here is what the ``lorewiki`` console-script entry point
|
|
7
|
+
binds to.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
# Order matters: ``apps`` first because every other module imports the
|
|
13
|
+
# Typer instances / console from it. ``helpers`` is dependency-free.
|
|
14
|
+
from lorewiki.cli import (
|
|
15
|
+
add, # noqa: F401 (registers `lorewiki add`)
|
|
16
|
+
apps, # noqa: F401 (side-effect: import decorators register)
|
|
17
|
+
commands, # noqa: F401 (registers init / index / …)
|
|
18
|
+
config_cmds, # noqa: F401 (registers config list / get / set)
|
|
19
|
+
helpers, # noqa: F401 (side-effect: re-export symbols)
|
|
20
|
+
topic_cmds, # noqa: F401 (registers topic …)
|
|
21
|
+
)
|
|
22
|
+
from lorewiki.cli.apps import app # re-exported for the entry point
|
|
23
|
+
from lorewiki.cli.helpers import print_phase_status # re-exported for tests
|
|
24
|
+
|
|
25
|
+
__all__ = ["app", "print_phase_status"]
|
lorewiki/cli/add.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""``lorewiki add`` — author a single knowledge note from the CLI.
|
|
2
|
+
|
|
3
|
+
The command takes a title (required) plus optional body/file/stdin
|
|
4
|
+
content, module, and tags, and writes a Markdown file with a YAML
|
|
5
|
+
frontmatter block into the active wiki. The index is re-built
|
|
6
|
+
afterwards so the new doc is immediately retrievable.
|
|
7
|
+
|
|
8
|
+
Path-traversal safety: the resolved target path is asserted to
|
|
9
|
+
live inside the wiki root before any write. Title and module are
|
|
10
|
+
slugified via the same rules used elsewhere (ascii / digits /
|
|
11
|
+
hyphens; no path separators).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Annotated, Any
|
|
23
|
+
|
|
24
|
+
import frontmatter
|
|
25
|
+
import typer
|
|
26
|
+
from rich.panel import Panel
|
|
27
|
+
|
|
28
|
+
from lorewiki.cli.apps import app, console, log
|
|
29
|
+
from lorewiki.cli.helpers import resolve_config
|
|
30
|
+
from lorewiki.indexer import build_index
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Slug + path safety helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_SLUG_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def slugify(text: str, *, max_len: int = 64) -> str:
|
|
40
|
+
"""Turn ``"My Note!"`` into ``"my-note"``.
|
|
41
|
+
|
|
42
|
+
The slug is used as a filename inside the topic. Rules:
|
|
43
|
+
* Lowercase, ASCII alphanumerics + ``-`` only.
|
|
44
|
+
* Collapse runs of non-alphanumerics into a single ``-``.
|
|
45
|
+
* Trim leading / trailing ``-``.
|
|
46
|
+
* Cap at ``max_len`` chars (default 64) so we don't bust the
|
|
47
|
+
path component limit on Windows.
|
|
48
|
+
"""
|
|
49
|
+
text = _SLUG_NON_ALNUM.sub("-", text.lower()).strip("-")
|
|
50
|
+
return text[:max_len] or "untitled"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_wiki_root(path_arg: str | None) -> Path:
|
|
54
|
+
"""Return the absolute path of the wiki root the add should land in."""
|
|
55
|
+
cfg = resolve_config(path_arg)
|
|
56
|
+
return cfg.wiki_path.resolve()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_safe_target(wiki_root: Path, target: Path) -> bool:
|
|
60
|
+
"""True iff ``target`` lives under ``wiki_root`` after resolution.
|
|
61
|
+
|
|
62
|
+
Blocks path-traversal attempts like ``--module ../../etc`` — the
|
|
63
|
+
slugified module may still end up containing ``..`` if the
|
|
64
|
+
caller asked for it literally, so we resolve both sides and
|
|
65
|
+
walk the ``Path.parents`` chain.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
target_resolved = target.resolve(strict=False)
|
|
69
|
+
except OSError:
|
|
70
|
+
return False
|
|
71
|
+
try:
|
|
72
|
+
root_resolved = wiki_root.resolve(strict=True)
|
|
73
|
+
except OSError:
|
|
74
|
+
return False
|
|
75
|
+
if target_resolved == root_resolved:
|
|
76
|
+
return False # never write *at* the root itself
|
|
77
|
+
return root_resolved in target_resolved.parents
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Body acquisition
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_body(body: str | None, file: Path | None) -> str:
|
|
86
|
+
"""Read the doc body from the first non-empty source.
|
|
87
|
+
|
|
88
|
+
Priority: ``--body`` → ``--file`` → ``sys.stdin`` (if not a TTY).
|
|
89
|
+
Returns the body text with a single trailing newline so the
|
|
90
|
+
frontmatter/body separator renders cleanly.
|
|
91
|
+
"""
|
|
92
|
+
if body is not None and body.strip():
|
|
93
|
+
return body.rstrip() + "\n"
|
|
94
|
+
if file is not None:
|
|
95
|
+
return file.read_text(encoding="utf-8").rstrip() + "\n"
|
|
96
|
+
if not sys.stdin.isatty():
|
|
97
|
+
data = sys.stdin.read()
|
|
98
|
+
if data.strip():
|
|
99
|
+
return data.rstrip() + "\n"
|
|
100
|
+
msg = (
|
|
101
|
+
"no content provided: pass --body, --file, or pipe data on stdin "
|
|
102
|
+
"(stdin is only read when not a TTY)"
|
|
103
|
+
)
|
|
104
|
+
raise typer.BadParameter(msg)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Title inference
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _extract_h1(body: str) -> str | None:
|
|
115
|
+
"""Return the first ``#`` heading of ``body``, or ``None``."""
|
|
116
|
+
match = H1_RE.search(body)
|
|
117
|
+
return match.group(1).strip() if match else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Frontmatter construction
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_frontmatter(
|
|
126
|
+
*,
|
|
127
|
+
title: str,
|
|
128
|
+
module: str,
|
|
129
|
+
tags: Iterable[str],
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Return a dict suitable for ``frontmatter.dumps``.
|
|
132
|
+
|
|
133
|
+
The CLI always sets ``title`` and ``module``; ``tags`` is a
|
|
134
|
+
list and serialises as a YAML list. ``created_at`` and
|
|
135
|
+
``last_review`` are pinned to the current UTC date so the
|
|
136
|
+
note has a usable staleness anchor out of the box.
|
|
137
|
+
"""
|
|
138
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
139
|
+
return {
|
|
140
|
+
"title": title,
|
|
141
|
+
"module": module,
|
|
142
|
+
"tags": list(tags),
|
|
143
|
+
"created_at": today,
|
|
144
|
+
"last_review": today,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Command
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command()
|
|
154
|
+
def add(
|
|
155
|
+
title: Annotated[
|
|
156
|
+
str,
|
|
157
|
+
typer.Option(
|
|
158
|
+
"--title",
|
|
159
|
+
"-t",
|
|
160
|
+
help="Document title. (Required, but can be auto-derived from "
|
|
161
|
+
"the first H1 of the body when not given.)",
|
|
162
|
+
),
|
|
163
|
+
] = "",
|
|
164
|
+
body: Annotated[
|
|
165
|
+
str | None,
|
|
166
|
+
typer.Option(
|
|
167
|
+
"--body",
|
|
168
|
+
"-b",
|
|
169
|
+
help="Inline Markdown body. Mutually exclusive with --file / stdin.",
|
|
170
|
+
),
|
|
171
|
+
] = None,
|
|
172
|
+
file: Annotated[
|
|
173
|
+
Path | None,
|
|
174
|
+
typer.Option(
|
|
175
|
+
"--file",
|
|
176
|
+
"-f",
|
|
177
|
+
help="Path to a local file to import as the body.",
|
|
178
|
+
),
|
|
179
|
+
] = None,
|
|
180
|
+
module: Annotated[
|
|
181
|
+
str,
|
|
182
|
+
typer.Option(
|
|
183
|
+
"--module",
|
|
184
|
+
"-m",
|
|
185
|
+
help="Module / category directory (default: 'root').",
|
|
186
|
+
),
|
|
187
|
+
] = "root",
|
|
188
|
+
tag: Annotated[
|
|
189
|
+
list[str] | None,
|
|
190
|
+
typer.Option(
|
|
191
|
+
"--tag",
|
|
192
|
+
help="Tag to attach (may be passed multiple times, e.g. "
|
|
193
|
+
"--tag python --tag design).",
|
|
194
|
+
),
|
|
195
|
+
] = None,
|
|
196
|
+
path: Annotated[
|
|
197
|
+
str | None,
|
|
198
|
+
typer.Option(
|
|
199
|
+
"--path",
|
|
200
|
+
"-p",
|
|
201
|
+
help="Project / wiki path. Defaults to the active topic.",
|
|
202
|
+
),
|
|
203
|
+
] = None,
|
|
204
|
+
force: Annotated[
|
|
205
|
+
bool,
|
|
206
|
+
typer.Option(
|
|
207
|
+
"--force",
|
|
208
|
+
help="Overwrite an existing file at the target path.",
|
|
209
|
+
),
|
|
210
|
+
] = False,
|
|
211
|
+
raw: Annotated[
|
|
212
|
+
bool,
|
|
213
|
+
typer.Option(
|
|
214
|
+
"--raw",
|
|
215
|
+
help="Emit a machine-readable JSON object on success.",
|
|
216
|
+
),
|
|
217
|
+
] = False,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Author a single knowledge note and re-index the wiki.
|
|
220
|
+
|
|
221
|
+
The body comes from one of (in priority order): ``--body``,
|
|
222
|
+
``--file``, then ``sys.stdin`` if it is not a TTY. The
|
|
223
|
+
destination path is ``<wiki>/<module>/<slug>.md``; if that file
|
|
224
|
+
already exists the command refuses unless ``--force`` is set.
|
|
225
|
+
|
|
226
|
+
After a successful write, an incremental ``build_index`` runs so
|
|
227
|
+
the new doc is immediately retrievable via ``lorewiki search``.
|
|
228
|
+
"""
|
|
229
|
+
# ---- 1. body & title ----------------------------------------------------
|
|
230
|
+
raw_body = _read_body(body, file)
|
|
231
|
+
final_title = title.strip() or _extract_h1(raw_body) or slugify(raw_body[:64])
|
|
232
|
+
|
|
233
|
+
# ---- 2. resolve paths ---------------------------------------------------
|
|
234
|
+
wiki_root = _resolve_wiki_root(path)
|
|
235
|
+
if not wiki_root.is_dir():
|
|
236
|
+
console.print(f"[red]wiki path not found:[/red] {wiki_root}")
|
|
237
|
+
raise typer.Exit(code=2)
|
|
238
|
+
|
|
239
|
+
module_slug = slugify(module) if module != "root" else "root"
|
|
240
|
+
title_slug = slugify(final_title)
|
|
241
|
+
target_dir = wiki_root / module_slug
|
|
242
|
+
target_path = target_dir / f"{title_slug}.md"
|
|
243
|
+
|
|
244
|
+
# ---- 3. path-traversal safety net -------------------------------------
|
|
245
|
+
if not _is_safe_target(wiki_root, target_path):
|
|
246
|
+
console.print(
|
|
247
|
+
Panel(
|
|
248
|
+
f"[red]Refusing to write outside the wiki root:[/red]\n"
|
|
249
|
+
f" target: {target_path}\n"
|
|
250
|
+
f" wiki: {wiki_root}\n"
|
|
251
|
+
f"Pass a safe --module / --title.",
|
|
252
|
+
title="path-traversal blocked",
|
|
253
|
+
border_style="red",
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
raise typer.Exit(code=3)
|
|
257
|
+
|
|
258
|
+
# ---- 4. conflict detection ---------------------------------------------
|
|
259
|
+
if target_path.exists() and not force:
|
|
260
|
+
console.print(
|
|
261
|
+
Panel(
|
|
262
|
+
f"[red]File already exists:[/red] {target_path}\n"
|
|
263
|
+
f"Pass [cyan]--force[/cyan] to overwrite.",
|
|
264
|
+
title="add: target exists",
|
|
265
|
+
border_style="red",
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
raise typer.Exit(code=4)
|
|
269
|
+
|
|
270
|
+
# ---- 5. write -----------------------------------------------------------
|
|
271
|
+
tags = list(tag) if tag else []
|
|
272
|
+
metadata = _build_frontmatter(title=final_title, module=module_slug, tags=tags)
|
|
273
|
+
post = frontmatter.Post(raw_body, **metadata)
|
|
274
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
try:
|
|
276
|
+
target_path.write_text(frontmatter.dumps(post) + "\n", encoding="utf-8")
|
|
277
|
+
except OSError as exc:
|
|
278
|
+
log.error("write failed {}: {}", target_path, exc)
|
|
279
|
+
console.print(f"[red]write failed:[/red] {exc}")
|
|
280
|
+
raise typer.Exit(code=5) from exc
|
|
281
|
+
|
|
282
|
+
# ---- 6. re-index --------------------------------------------------------
|
|
283
|
+
try:
|
|
284
|
+
cfg = resolve_config(path)
|
|
285
|
+
build_index(cfg, rebuild=False)
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
# bug to swallow a successful write. Surface the warning and let the
|
|
288
|
+
# user re-run ``lorewiki index`` later.
|
|
289
|
+
log.warning("post-write reindex failed: {}", exc)
|
|
290
|
+
console.print(
|
|
291
|
+
f"[yellow]warning: reindex failed[/yellow] (the file is on disk, "
|
|
292
|
+
f"but you may want to run `lorewiki index`): {exc}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# ---- 7. output ---------------------------------------------------------
|
|
296
|
+
if raw:
|
|
297
|
+
typer.echo(
|
|
298
|
+
json.dumps(
|
|
299
|
+
{
|
|
300
|
+
"ok": True,
|
|
301
|
+
"title": final_title,
|
|
302
|
+
"module": module_slug,
|
|
303
|
+
"path": str(target_path),
|
|
304
|
+
"tags": tags,
|
|
305
|
+
},
|
|
306
|
+
ensure_ascii=False,
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
console.print(
|
|
312
|
+
Panel(
|
|
313
|
+
f"[green]wrote[/green] [bold]{target_path}[/bold]\n"
|
|
314
|
+
f" title : {final_title}\n"
|
|
315
|
+
f" module: {module_slug}\n"
|
|
316
|
+
f" tags : {', '.join(tags) if tags else '[dim](none)[/dim]'}\n\n"
|
|
317
|
+
f"Try: [cyan]lorewiki search \"{final_title}\"[/cyan]",
|
|
318
|
+
title="lorewiki add",
|
|
319
|
+
border_style="green",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
__all__ = ["add", "slugify"]
|
lorewiki/cli/apps.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Typer app instances, Rich console, and the ASCII banner.
|
|
2
|
+
|
|
3
|
+
The :class:`Typer` instances live here (not in :mod:`lorewiki.cli.__init__`)
|
|
4
|
+
so that the command modules can import them without dragging in
|
|
5
|
+
the subcommand decorators, which would defeat Typer's lazy
|
|
6
|
+
registration. ``__init__.py`` then imports the ``app`` symbol so the
|
|
7
|
+
existing ``lorewiki.cli:app`` console-script entry point keeps working.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
|
|
16
|
+
import click as _click
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from lorewiki import __version__
|
|
21
|
+
from lorewiki.utils.logger import get_logger
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
log = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _force_utf8_streams() -> None:
|
|
28
|
+
"""Force UTF-8 on stdout/stderr at import time.
|
|
29
|
+
|
|
30
|
+
Without this, ``--raw`` JSON containing CJK characters becomes
|
|
31
|
+
mojibake on Windows shells whose default code page is GBK
|
|
32
|
+
(cp936). Python 3.7+ exposes ``TextIOWrapper.reconfigure``; we
|
|
33
|
+
guard with ``hasattr`` so that non-standard stdouts captured by
|
|
34
|
+
tests don't crash the CLI.
|
|
35
|
+
"""
|
|
36
|
+
for stream in (sys.stdout, sys.stderr):
|
|
37
|
+
if hasattr(stream, "reconfigure"):
|
|
38
|
+
with contextlib.suppress(OSError, ValueError):
|
|
39
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_force_utf8_streams()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# ASCII banner
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
#
|
|
49
|
+
# Hand-laid block-character logo, designed to render cleanly on Windows
|
|
50
|
+
# Terminal / PowerShell 7+ / macOS Terminal. Six lines high, ~56 cols wide.
|
|
51
|
+
_BANNER_LINES = (
|
|
52
|
+
" ██╗ ██████╗ ██████╗ ███████╗██╗ ██╗██╗██╗ ██╗██╗",
|
|
53
|
+
" ██║ ██╔═══██╗██╔══██╗██╔════╝██║ ██║██║██║ ██╔╝██║",
|
|
54
|
+
" ██║ ██║ ██║██████╔╝█████╗ ██║ █╗ ██║██║█████╔╝ ██║",
|
|
55
|
+
" ██║ ██║ ██║██╔══██╗██╔══╝ ██║███╗██║██║██╔═██╗ ██║",
|
|
56
|
+
" ███████╗╚██████╔╝██║ ██║███████╗╚███╔███╔╝██║██║ ██╗██║",
|
|
57
|
+
" ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚══╝╚══╝ ╚═╝╚═╝ ╚═╝╚═╝",
|
|
58
|
+
)
|
|
59
|
+
_BANNER_HELP = "\n".join(_BANNER_LINES) + "\n"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_banner() -> None:
|
|
63
|
+
"""Print the LOREWIKI block-character banner in cyan + bold."""
|
|
64
|
+
console.print(_BANNER_HELP.rstrip(), style="bold cyan", highlight=False)
|
|
65
|
+
console.print(
|
|
66
|
+
f" [dim]local-first knowledge base \u00b7 v{__version__}[/dim]",
|
|
67
|
+
highlight=False,
|
|
68
|
+
)
|
|
69
|
+
console.print()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
app = typer.Typer(
|
|
73
|
+
name="lorewiki",
|
|
74
|
+
help="Local-first knowledge base for LLM-assisted coding.",
|
|
75
|
+
no_args_is_help=True,
|
|
76
|
+
add_completion=False,
|
|
77
|
+
rich_markup_mode="rich",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
config_app = typer.Typer(
|
|
81
|
+
name="config",
|
|
82
|
+
help="View and edit LoreWiki configuration.",
|
|
83
|
+
no_args_is_help=True,
|
|
84
|
+
)
|
|
85
|
+
app.add_typer(config_app, name="config")
|
|
86
|
+
|
|
87
|
+
topic_app = typer.Typer(
|
|
88
|
+
name="topic",
|
|
89
|
+
help=(
|
|
90
|
+
"Manage knowledge topics (your 'second-brain' vaults). "
|
|
91
|
+
"Each topic is an isolated index under ~/lorewiki/topics/<name>/."
|
|
92
|
+
),
|
|
93
|
+
no_args_is_help=True,
|
|
94
|
+
)
|
|
95
|
+
app.add_typer(topic_app, name="topic")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _version_callback(value: bool) -> None:
|
|
99
|
+
if value:
|
|
100
|
+
print_banner()
|
|
101
|
+
raise typer.Exit()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _help_callback(ctx: typer.Context, value: bool) -> None:
|
|
105
|
+
"""Top-level ``--help`` / ``-h`` shortcut that prepends the banner.
|
|
106
|
+
|
|
107
|
+
typer 0.9+ auto-generates ``--help`` *before* user code runs, so the
|
|
108
|
+
banner injection via :func:`_get_help_with_banner` never fires. We
|
|
109
|
+
therefore define an explicit ``--help`` option with ``is_eager=True``;
|
|
110
|
+
typer defers to the user-defined callback whenever an option with
|
|
111
|
+
that name exists. The standard typer-rendered help text is then
|
|
112
|
+
fetched via :meth:`typer.Context.get_help` so all subcommand /
|
|
113
|
+
option metadata still flows through the normal path.
|
|
114
|
+
"""
|
|
115
|
+
if not value:
|
|
116
|
+
return
|
|
117
|
+
print_banner()
|
|
118
|
+
# typer's Context exposes ``get_help`` which delegates to click's
|
|
119
|
+
# ``Context.get_help`` on the bound click.Command, so the output
|
|
120
|
+
# matches what the auto-generated help would have shown.
|
|
121
|
+
console.print(ctx.get_help(), highlight=False)
|
|
122
|
+
raise typer.Exit(0)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.callback()
|
|
126
|
+
def main(
|
|
127
|
+
version: Annotated[
|
|
128
|
+
bool,
|
|
129
|
+
typer.Option(
|
|
130
|
+
"--version",
|
|
131
|
+
"-V",
|
|
132
|
+
help="Show LoreWiki version and exit.",
|
|
133
|
+
callback=_version_callback,
|
|
134
|
+
is_eager=True,
|
|
135
|
+
),
|
|
136
|
+
] = False,
|
|
137
|
+
help_: Annotated[
|
|
138
|
+
bool,
|
|
139
|
+
typer.Option(
|
|
140
|
+
"--help",
|
|
141
|
+
"-h",
|
|
142
|
+
help="Show this message and exit.",
|
|
143
|
+
callback=_help_callback,
|
|
144
|
+
is_eager=True,
|
|
145
|
+
),
|
|
146
|
+
] = False,
|
|
147
|
+
topic: Annotated[
|
|
148
|
+
str | None,
|
|
149
|
+
typer.Option(
|
|
150
|
+
"--topic",
|
|
151
|
+
"-t",
|
|
152
|
+
help=(
|
|
153
|
+
"Active knowledge topic for this invocation. Overrides "
|
|
154
|
+
"LOREWIKI_TOPIC env and ~/lorewiki/current. Use `lorewiki "
|
|
155
|
+
"topic list` to see available topics."
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
] = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""LoreWiki CLI entrypoint."""
|
|
161
|
+
import os # noqa: PLC0415
|
|
162
|
+
|
|
163
|
+
# Plumb the topic into the process environment so :func:`load_config`
|
|
164
|
+
# picks it up uniformly across CLI subcommands. We only set it
|
|
165
|
+
# when non-empty, so a bare ``--topic ""`` is a no-op rather than
|
|
166
|
+
# an explicit reset.
|
|
167
|
+
if topic:
|
|
168
|
+
os.environ["LOREWIKI_TOPIC"] = topic
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _get_help_with_banner(self: typer.Typer, ctx: typer.Context) -> str: # type: ignore[override]
|
|
172
|
+
return _BANNER_HELP + _click.Group.get_help(self, ctx)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Prepend the ASCII banner to every `--help` output. typer wraps each
|
|
176
|
+
# :class:`Typer` in a click.Group at first dispatch, so the `get_help`
|
|
177
|
+
# method only materialises on the instance — we rebind it here.
|
|
178
|
+
app.get_help = _get_help_with_banner # type: ignore[method-assign]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = ["app", "config_app", "console", "log", "print_banner", "topic_app"]
|