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 ADDED
@@ -0,0 +1,4 @@
1
+ """LoreWiki - Local-first knowledge base for LLM-assisted coding."""
2
+
3
+ __version__ = "0.2.1"
4
+ __all__ = ["__version__"]
lorewiki/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running the CLI via ``python -m lorewiki``."""
2
+
3
+ from lorewiki.cli import app
4
+
5
+ if __name__ == "__main__": # pragma: no cover - exercised by subprocess tests
6
+ app()
@@ -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"]