lorewiki 0.2.4__tar.gz → 0.2.6__tar.gz

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 (42) hide show
  1. {lorewiki-0.2.4 → lorewiki-0.2.6}/PKG-INFO +1 -1
  2. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/__init__.py +1 -1
  3. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/__init__.py +1 -0
  4. lorewiki-0.2.6/lorewiki/cli/install_cmd.py +98 -0
  5. lorewiki-0.2.6/lorewiki/data/skill_template/SKILL.md +552 -0
  6. lorewiki-0.2.6/lorewiki/utils/skill_installer.py +391 -0
  7. {lorewiki-0.2.4 → lorewiki-0.2.6}/pyproject.toml +9 -2
  8. {lorewiki-0.2.4 → lorewiki-0.2.6}/.gitignore +0 -0
  9. {lorewiki-0.2.4 → lorewiki-0.2.6}/LICENSE +0 -0
  10. {lorewiki-0.2.4 → lorewiki-0.2.6}/README.md +0 -0
  11. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/__main__.py +0 -0
  12. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/add.py +0 -0
  13. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/apps.py +0 -0
  14. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/commands.py +0 -0
  15. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/config_cmds.py +0 -0
  16. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/helpers.py +0 -0
  17. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/topic_cmds.py +0 -0
  18. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/config.py +0 -0
  19. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/db/__init__.py +0 -0
  20. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/db/connection.py +0 -0
  21. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/db/models.py +0 -0
  22. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/db/schema.sql +0 -0
  23. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/indexer/__init__.py +0 -0
  24. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/indexer/chunker.py +0 -0
  25. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/indexer/cleaning.py +0 -0
  26. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/indexer/indexer.py +0 -0
  27. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/indexer/parser.py +0 -0
  28. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/llm/__init__.py +0 -0
  29. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/llm/client.py +0 -0
  30. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/llm/generator.py +0 -0
  31. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/py.typed +0 -0
  32. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/__init__.py +0 -0
  33. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/base.py +0 -0
  34. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/bm25.py +0 -0
  35. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/fusion.py +0 -0
  36. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/hierarchy.py +0 -0
  37. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/search.py +0 -0
  38. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/vector.py +0 -0
  39. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/topic.py +0 -0
  40. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/utils/__init__.py +0 -0
  41. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/utils/logger.py +0 -0
  42. {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/utils/topic_shared.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lorewiki
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Local-first knowledge base for LLM-assisted coding, with hybrid retrieval (BM25 + hierarchy + optional vector) over SQLite FTS5.
5
5
  Project-URL: Documentation, https://github.com/JochenYang/Lore-wiki
6
6
  Project-URL: Source, https://github.com/JochenYang/Lore-wiki
@@ -1,4 +1,4 @@
1
1
  """LoreWiki - Local-first knowledge base for LLM-assisted coding."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.2.6"
4
4
  __all__ = ["__version__"]
@@ -17,6 +17,7 @@ from lorewiki.cli import (
17
17
  commands, # noqa: F401 (registers init / index / …)
18
18
  config_cmds, # noqa: F401 (registers config list / get / set)
19
19
  helpers, # noqa: F401 (side-effect: re-export symbols)
20
+ install_cmd, # noqa: F401 (registers `lorewiki install`)
20
21
  topic_cmds, # noqa: F401 (registers topic …)
21
22
  )
22
23
  from lorewiki.cli.apps import app # re-exported for the entry point
@@ -0,0 +1,98 @@
1
+ """``lorewiki install`` — install / uninstall / inspect the agent skill.
2
+
3
+ This is the wheel-side counterpart of the source-tree
4
+ ``skills/install.py``. A user who installed ``lorewiki`` from PyPI
5
+ runs ``lorewiki install`` (no need to clone the repository); a
6
+ developer who already has the source tree checked out can still
7
+ use ``python skills/install.py`` — the two paths share the same
8
+ TOOL catalog, prompt grammar, and install semantics.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from lorewiki.cli.apps import app
17
+ from lorewiki.utils.skill_installer import run as _run
18
+
19
+
20
+ @app.command()
21
+ def install(
22
+ tool: Annotated[
23
+ str | None,
24
+ typer.Option(
25
+ "--tool",
26
+ help=(
27
+ "Comma-separated tool ids to install into (e.g. "
28
+ "'opencode,claude,codex'). If omitted and --all is also "
29
+ "omitted, an interactive multi-select prompt is shown."
30
+ ),
31
+ ),
32
+ ] = None,
33
+ all_: Annotated[
34
+ bool,
35
+ typer.Option(
36
+ "--all",
37
+ help=(
38
+ "Install into every detected tool without prompting. "
39
+ "A tool is 'detected' when its config root "
40
+ "(e.g. ~/.config/opencode) already exists on this machine."
41
+ ),
42
+ ),
43
+ ] = False,
44
+ uninstall: Annotated[
45
+ bool,
46
+ typer.Option(
47
+ "--uninstall",
48
+ help="Remove the skill from the target tool directories instead of installing.",
49
+ ),
50
+ ] = False,
51
+ force: Annotated[
52
+ bool,
53
+ typer.Option(
54
+ "--force",
55
+ help="Overwrite an existing install at the target path.",
56
+ ),
57
+ ] = False,
58
+ status: Annotated[
59
+ bool,
60
+ typer.Option(
61
+ "--status",
62
+ help="Print where the skill is currently installed and exit.",
63
+ ),
64
+ ] = False,
65
+ ) -> None:
66
+ """Install the LoreWiki agent skill into one or more AI tool directories.
67
+
68
+ By default the command prints a numbered list of detected tools
69
+ (the AI coding tools whose config root is already on this
70
+ machine) and asks you to pick. The prompt accepts:
71
+
72
+ \b
73
+ - a single index (``3``)
74
+ - multiple indices (``1,3,5`` or ``1 3 5``)
75
+ - a range (``2-4``)
76
+ - mixed forms (``1,3-5,6``)
77
+ - ``a`` to install into every detected tool
78
+ - ``q`` (or empty) to quit without installing
79
+
80
+ Pass ``--tool`` or ``--all`` to skip the prompt. ``--uninstall``
81
+ reverses the operation; ``--force`` overwrites an existing
82
+ install at the target path.
83
+ """
84
+ tool_ids = (
85
+ [s.strip() for s in tool.split(",") if s.strip()]
86
+ if tool is not None
87
+ else None
88
+ )
89
+ rc = _run(
90
+ tool_ids=tool_ids,
91
+ install_all=all_,
92
+ uninstall=uninstall,
93
+ force=force,
94
+ show_status=status,
95
+ )
96
+ if rc:
97
+ raise typer.Exit(code=rc)
98
+
@@ -0,0 +1,552 @@
1
+ ---
2
+ name: lorewiki
3
+ description: "Local-first Markdown knowledge base with hybrid retrieval (BM25 + heading-hierarchy + RRF) and optional LLM answer generation. Use when the user wants to look something up in an internal wiki, ask a question grounded in their team docs, browse the hierarchy of stored notes, or persist a learning / decision / postmortem into a queryable store. Triggers on the natural-language intent to recall or save knowledge — the LLM should match by meaning, not by exact keyword. CLI is one shell call per command with JSON output by default for `search` / `show` / `tree`."
4
+ ---
5
+
6
+ # lorewiki
7
+
8
+ Local-first Markdown knowledge base. Index a directory of `.md` files into
9
+ SQLite + FTS5, then retrieve / answer / browse / author via the `lorewiki` CLI.
10
+
11
+ **Why this skill**: every command is one shell call, output is structured
12
+ JSON by default for `search`/`show`/`tree` (no flag needed), no daemon
13
+ to keep alive, no client config, no server to run. Works inside
14
+ opencode / Codex / Aider / cron / CI scripts.
15
+
16
+ **Output convention** (v0.2.0+):
17
+
18
+ | Command | Default | Human-readable flag |
19
+ |----------------|--------------------------|----------------------------|
20
+ | `search` | JSON (for agents) | `--human` (Rich Table) |
21
+ | `tree` | Rich Tree | (always human) |
22
+ | `show` | Cleaned markdown body | `--raw` (on-disk verbatim) |
23
+ | `ask` | Markdown-rendered answer | `--raw` (JSON) |
24
+ | `topic list` | Rich panel | `--raw` (JSON) |
25
+ | `add` | Rich panel | `--raw` (JSON) |
26
+
27
+ ## When To Use
28
+
29
+ Invoke this skill whenever the user wants to:
30
+
31
+ - **Read** — search the wiki (`lorewiki search`), ask a question
32
+ (`lorewiki ask`), browse the hierarchy (`lorewiki tree`), or dump a
33
+ single doc (`lorewiki show`).
34
+ - **Write** — author a single note end-to-end via `lorewiki add`. The
35
+ command takes body via `--body` / `--file` / stdin, slugifies the
36
+ title into a filename, writes a Markdown file with frontmatter,
37
+ and triggers an incremental `build_index` so the new doc is
38
+ immediately retrievable. Use this whenever the user wants to
39
+ persist a learning, decision, postmortem, or any small chunk of
40
+ knowledge into the wiki.
41
+ - **Index** — refresh the SQLite index from disk after manual edits
42
+ to .md files (`lorewiki index`). `add` runs this for you.
43
+ - **Inspect** — `lorewiki status` shows chunk / doc / last-indexed
44
+ counts; `lorewiki topic list` enumerates topics.
45
+
46
+ Trigger words: `wiki`, `knowledge base`, `lorewiki`, `internal docs`,
47
+ `runbook`, `postmortem`, `team docs` — and the obvious
48
+ language-localised equivalents in whatever language the user is
49
+ using. The LLM should match by *intent* (recall / save a note /
50
+ browse the structure), not by exact keyword match.
51
+
52
+ ## Prerequisites
53
+
54
+ The `lorewiki` CLI must be on the user's PATH. Check with:
55
+
56
+ ```powershell
57
+ lorewiki --version # expect: LoreWiki 0.1.0 (or newer)
58
+ ```
59
+
60
+ If missing:
61
+
62
+ ```powershell
63
+ # From the source repo (editable install — easiest for active development)
64
+ pip install -e D:/codes/Lorewiki
65
+
66
+ # Or, once published, via pipx for an isolated global install
67
+ pipx install lorewiki
68
+ ```
69
+
70
+ The user must also have a wiki directory with at least `<wiki>/.lorewiki/config.toml`.
71
+ Initialise one if absent: `lorewiki init --path <PATH>`.
72
+
73
+ ## Path Handling Convention
74
+
75
+ Every `lorewiki` invocation takes an optional `--path <WIKI_ROOT>`. The
76
+ agent should pick a wiki using **this exact priority chain**:
77
+
78
+ 1. **Explicit value the user typed** in their message — use it verbatim.
79
+ 2. **Environment variable** `LOREWIKI_WIKI_PATH` — `$env:LOREWIKI_WIKI_PATH`
80
+ on Windows, `$LOREWIKI_WIKI_PATH` on macOS/Linux. Power users set this so
81
+ they never have to specify `--path`.
82
+ 3. **`.lorewiki/config.toml` in cwd or any ancestor** of cwd — fastest probe:
83
+ ```powershell
84
+ Get-Item .\.lorewiki\config.toml -ErrorAction SilentlyContinue
85
+ ```
86
+ 4. **Bounded filesystem scan** — when none of the above resolved, look in
87
+ common roots with a **shallow** depth (don't scan the entire D:\ or $HOME):
88
+ ```powershell
89
+ Get-ChildItem -Path D:\codes, $env:USERPROFILE\Documents `
90
+ -Filter ".lorewiki" -Recurse -Directory -Depth 3 `
91
+ -ErrorAction SilentlyContinue | Select-Object FullName
92
+ ```
93
+ 5. **Multiple candidates** — call `lorewiki status --path <CAND>` on each;
94
+ pick the one with the highest `Chunks` count (it's the populated wiki).
95
+ Document the choice once, cache it for the rest of the conversation.
96
+ 6. **No candidates at all** — ask the user which wiki to use, or offer
97
+ `lorewiki init --path <PATH>` to bootstrap a new one.
98
+
99
+ **Reproducibility rule**: always pass `--path "<ABS_PATH>"` in the actual
100
+ shell call so the command is portable; never rely on cwd at the call site.
101
+ On Windows use forward slashes in arguments to dodge quoting issues:
102
+ `--path D:/codes/Lorewiki/example_wiki`.
103
+
104
+ ## Topics (second-brain vaults)
105
+
106
+ By default `lorewiki search` queries whichever topic is active. Topics
107
+ are isolated vaults under `~/lorewiki/topics/<name>/`, shared across
108
+ every project the user works in. Discovery flow when the user asks
109
+ "look up X in my react notes" or "search the cocos wiki":
110
+
111
+ 1. `lorewiki topic list --raw` — enumerate. The active one is starred
112
+ (`*`); its name is the value of `~/lorewiki/current`.
113
+ 2. If no topic is active or the user wants a different one, run
114
+ `lorewiki topic use <name>` first.
115
+ 3. If the user mentions a topic that doesn't exist yet, follow the
116
+ **Naming Protocol** below to pick a name and confirm with the user.
117
+ 4. The active topic's wiki root doubles as an Obsidian / Logseq
118
+ vault — the user may prefer to edit Markdown directly and just
119
+ re-run `lorewiki index` (which is incremental).
120
+
121
+ ### Naming Protocol
122
+
123
+ When the user says "make me a wiki for X" and X is a free-form
124
+ description (e.g. "react hooks learning", "wechat miniprogram dev"),
125
+ **do not** silently invent a name. Follow this protocol:
126
+
127
+ 1. `lorewiki topic list --raw` — check for name collisions and pick
128
+ the active topic (if any) for context.
129
+ 2. `lorewiki topic suggest "<X description>"` — get 1-4 candidate
130
+ slugs. The algorithm is rule-based (slugify + stopword removal);
131
+ for CJK-only descriptions it returns nothing.
132
+ 3. **Show the user the candidates and ask which to use.** Don't
133
+ auto-pick. Example reply:
134
+ > I can call this `wechat-mp`, `wechat-miniprogram`, or `mp`. Which
135
+ > do you prefer? (Or pick your own name — rules: lowercase,
136
+ > digits, hyphens, 1-64 chars.)
137
+ 4. `lorewiki topic create <chosen> [--source <path-to-existing-md>]`.
138
+ Default mode copies; `--link` symlinks instead.
139
+ 5. If the user later dislikes the name, `lorewiki topic rename
140
+ <old> <new>` renames in place (the index and config move with
141
+ it; the active pointer is updated if applicable).
142
+
143
+ `topic suggest` is **English-friendly** by design. For CJK
144
+ descriptions, the command exits with code 1 and prints a panel that
145
+ tells the user to name the topic by hand. The agent should fall
146
+ back to asking the user explicitly in that case.
147
+
148
+ **Path resolution priority** (later wins):
149
+ 1. `--topic` flag
150
+ 2. `LOREWIKI_TOPIC` env var
151
+ 3. `~/lorewiki/current` file (set by `lorewiki topic use`)
152
+ 4. `--path` flag (legacy per-wiki mode)
153
+ 5. cwd `.lorewiki/config.toml` (legacy per-project mode)
154
+
155
+ Topic names: lowercase ASCII, digits, hyphens, 1-64 chars, no
156
+ leading/trailing hyphens. The `lorewiki topic create` command will
157
+ reject anything else with a clear error.
158
+
159
+ **Important**: a `~/.lorewiki/topics/<name>/.lorewiki/index.db` only
160
+ exists *after* `lorewiki index` has been run. If the user asks to
161
+ search a brand-new topic, expect the `No index found` panel — point
162
+ them at `lorewiki topic use <name> && lorewiki index`.
163
+
164
+ ## Core Workflows
165
+
166
+ ### 1. Retrieve and cite (most common)
167
+
168
+ User asks something likely covered by team docs ⇒ search, then ground the
169
+ answer in the returned chunks with file-path citations.
170
+
171
+ ```powershell
172
+ lorewiki search "<question or keywords>" `
173
+ --path "<WIKI>" --mode mix --top-k 5
174
+ ```
175
+
176
+ **No `--raw` flag** for `search` — JSON is the default. Pass `--human` if
177
+ you actually want a Rich Table for terminal eyeballing (rare for agents).
178
+
179
+ Returned JSON shape (parse it; don't grep the prettified panel):
180
+
181
+ ```json
182
+ [
183
+ {
184
+ "chunk_id": "api/user/auth.md#0",
185
+ "doc_path": "api/user/auth.md",
186
+ "title": "Authentication API",
187
+ "heading_path": "Auth API > Overview",
188
+ "module": "api/user",
189
+ "snippet": "...",
190
+ "score": 0.029,
191
+ "retriever": "mix"
192
+ },
193
+ ...
194
+ ]
195
+ ```
196
+
197
+ The `snippet` field contains the **full chunk body** (anchor markup, scraper
198
+ boilerplate, and the translation footer are stripped at index time — see
199
+ `lorewiki.indexer.cleaning` for the rules). The `title` has no leading `#`
200
+ and `heading_path` is ` > `-joined segments with anchors removed. The
201
+ breadcrumb prefix that the chunker adds for FTS recall is also stripped
202
+ from the snippet (it's already in `heading_path`).
203
+
204
+ Then compose an answer that:
205
+ - quotes the relevant snippets,
206
+ - cites each fact with its `doc_path` (and optionally `heading_path`),
207
+ - does NOT fabricate beyond what the snippets say.
208
+
209
+ ### 2. LLM-assisted answer
210
+
211
+ When the user explicitly wants a synthesised answer (not just chunks),
212
+ use `ask`. It already does retrieval + prompt assembly + LLM call:
213
+
214
+ ```powershell
215
+ lorewiki ask "<question>" --path "<WIKI>" --top-k 5 --raw
216
+ ```
217
+
218
+ `ask` defaults to a Markdown-rendered panel (human-friendly); add
219
+ `--raw` to get the JSON shape with `answer`, `used_llm`, `degraded_reason`,
220
+ and `hits`. If `used_llm == false`, hand the `answer` text straight back
221
+ to the user — no need for a second tool call.
222
+
223
+ ### 3. Discover the structure (before broad questions)
224
+
225
+ If the user's request is broad ("what's in the wiki?", "what modules do we
226
+ have?"), use the tree view:
227
+
228
+ ```powershell
229
+ lorewiki tree # full hierarchy
230
+ lorewiki tree api/share --depth 3 # sub-tree with depth limit
231
+ ```
232
+
233
+ For "show me everything under module X" style queries, search in hierarchy
234
+ mode (returns chunks grouped by the matched tree node):
235
+
236
+ ```powershell
237
+ lorewiki search "<module name>" --path "<WIKI>" --mode hierarchy --top-k 10
238
+ ```
239
+
240
+ ### 4. Write a new note to the wiki (knowledge persistence)
241
+
242
+ Follow this template strictly so the indexer picks up the right metadata:
243
+
244
+ ```markdown
245
+ ---
246
+ title: "Decision: switch to Redis Streams for event bus"
247
+ module: decisions
248
+ tags: [decision, infra, redis]
249
+ owner: platform-team
250
+ last_review: 2026-06-10
251
+ ---
252
+
253
+ # Decision: switch to Redis Streams
254
+
255
+ ## Context
256
+ <why we are deciding this>
257
+
258
+ ## Decision
259
+ <what we will do>
260
+
261
+ ## Consequences
262
+ <trade-offs + follow-ups>
263
+ ```
264
+
265
+ **Frontmatter rules** (the indexer reads these fields):
266
+
267
+ | Field | Required | Notes |
268
+ |---------------|-----------------|----------------------------------------------------------------------------------|
269
+ | `title` | best-effort | Frontmatter wins, else first H1, else the filename. Always set it explicitly. |
270
+ | `module` | best-effort | Logical hierarchy path (e.g. `decisions`, `api/user`). See §Path semantics below. |
271
+ | `tags` | optional | Free-form list; aids hierarchy search. |
272
+ | `owner` | optional | Team or person responsible. |
273
+ | `last_review` | optional | ISO date; helps with staleness audits. |
274
+
275
+ Place it under the right module directory, then re-index:
276
+
277
+ ```powershell
278
+ # 1) Write the file (use the Write/Edit tool — never `Out-File`, BOM issues)
279
+ # Target path example: D:/codes/Lorewiki/example_wiki/decisions/redis-streams.md
280
+
281
+ # 2) Re-index (incremental — only changed files are re-processed)
282
+ lorewiki index --path "<WIKI>"
283
+ ```
284
+
285
+ Verify by searching for a distinctive phrase from the new doc:
286
+
287
+ ```powershell
288
+ lorewiki search "Redis Streams decision" --path "<WIKI>" --mode mix --top-k 3
289
+ ```
290
+
291
+ #### Path semantics (read this once, it shapes the whole vault)
292
+
293
+ The `module:` field is a **logical category**, not a physical file path.
294
+ The two don't have to match (the parser doesn't enforce it), but
295
+ **keep them aligned** so the hierarchy tree is useful for browsing:
296
+
297
+ - **Aligned** (recommended): file at `api/user/auth.md` has
298
+ `module: api/user`. Walking the hierarchy gives you
299
+ `api/ → api/user/ → docs` and the file shows up under
300
+ `api/user` in `lorewiki status`.
301
+ - **Mis-aligned** (allowed, but degrades the UI): file at
302
+ `patterns/rate-limit.md` with `module: patterns` — the
303
+ hierarchy only shows one level (`patterns`), but the file still
304
+ indexes and searches normally.
305
+
306
+ **Rule of thumb**: when in doubt, set `module` to the directory the
307
+ file is in (or a sensible parent).
308
+
309
+ #### Quality checklist for scraped / external content
310
+
311
+ If you are **fetching** documentation and writing it into the vault
312
+ (common workflow in the user's AI tools), the indexer will accept
313
+ anything but search quality collapses on certain patterns. **Don't**:
314
+
315
+ | Anti-pattern | Why it breaks | What to do instead |
316
+ |-----------------------------------------------------------|--------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
317
+ | Filename ending in `.html` (e.g. `wx.arrayBufferToBase64.html`) | Obsidian / Logseq render the file as raw HTML source; internal links fail to resolve | Strip the `.html` extension. The file is markdown, not HTML. |
318
+ | `_index.md` (underscore-prefixed) | LoreWiki has no special handling for `_index.md`; you'll get an empty `title: ""` and the indexer falls back to the first H1, which is often a quote or nav block | Use plain `index.md` (or a topic-specific name like `api-overview.md`). |
319
+ | `title: ""` (empty in frontmatter) | The indexer falls back to the first H1 — if the first H1 is a quote, a nav link, or a heading with special characters, search titles become ugly | Always fill `title` with a short, human-readable summary. |
320
+ | Internal links pointing to `.html` files | `(/api/base/wx.env.html)` — Obsidian and the indexer can't follow them | Strip `.html` from the link target, or convert to the equivalent `.md` path. |
321
+ | Module path with spaces / capital letters | The hierarchy tree treats it as a distinct node; search ranks it as a separate entity | Use kebab-case lowercase segments: `api/user`, `patterns/rate-limit`, `decisions/redis`. |
322
+ | Scrape artefact files in the vault root (e.g. `manifest.json`, `scrape.log`) | These pollute the Obsidian vault view; they don't index but they confuse the user | Put scrape artefacts in a separate cache directory (`~/.lorewiki/.scrape-cache/<topic>/`), not in the vault |
323
+
324
+ After the write, run `lorewiki index --path "<WIKI>"` and spot-check
325
+ with `lorewiki status --path "<WIKI>"` — the chunk count and
326
+ hierarchy depth will tell you if the structure is sensible.
327
+
328
+ ### 5. Fresh wiki bootstrap
329
+
330
+ ```powershell
331
+ lorewiki init --path "<NEW_WIKI_DIR>"
332
+ # Author Markdown files under <NEW_WIKI_DIR>/...
333
+ lorewiki index --path "<NEW_WIKI_DIR>" --rebuild
334
+ lorewiki status --path "<NEW_WIKI_DIR>"
335
+ ```
336
+
337
+ ### 6. Configuration inspection / change
338
+
339
+ > **Single source of truth**: all lorewiki config lives in **one**
340
+ > file: `~/.lorewiki/config.toml`. We **no longer** drop a
341
+ > `config.toml` into each topic root (it polluted the vault view
342
+ > in Obsidian / Logseq). If you need per-topic overrides, edit the
343
+ > global file with a `[topics.<name>]` section header.
344
+
345
+ There is **no config.toml to discover** until the user creates one.
346
+ The recommended way to bootstrap it:
347
+
348
+ ```powershell
349
+ # Option A — interactive, recommended for humans
350
+ lorewiki config set llm.enabled true
351
+ lorewiki config set llm.backend '"openai"'
352
+ lorewiki config set llm.openai_api_key '"sk-..."'
353
+ lorewiki config set llm.openai_model '"gpt-4o-mini"'
354
+ # (each call writes ~/.lorewiki/config.toml; subsequent `lorewiki config list`
355
+ # shows the merged result)
356
+
357
+ # Option B — write the file directly
358
+ # Recommended for headless / CI use, and the only way to set
359
+ # OpenAI-compatible endpoints that point at OpenRouter / vLLM / etc.
360
+ notepad ~/.lorewiki/config.toml # or your editor of choice
361
+ ```
362
+
363
+ A fully-populated `~/.lorewiki/config.toml` (every supported key
364
+ is shown, comments mark the lines you'll most often change):
365
+
366
+ ```toml
367
+ # ~/.lorewiki/config.toml — LoreWiki's single config file.
368
+ # Anything you don't set here falls back to the in-code default.
369
+
370
+ # ----- Retrieval -----
371
+ retrieval_mode = "mix" # mix | bm25 | hierarchy | vector
372
+ mix_weights_bm25 = 1.0
373
+ mix_weights_hierarchy = 0.8
374
+ mix_weights_vector = 0.5
375
+ rrf_k = 60 # RRF smoothing constant
376
+ chunk_max_tokens = 800
377
+ chunk_overlap_tokens = 100
378
+ chunk_min_chars = 40
379
+ snippet_chars = 240
380
+
381
+ # ----- LLM (optional) -----
382
+ # Enabled = false by default. When false, `lorewiki ask` falls
383
+ # back to the top-K chunks panel (graceful degradation).
384
+ [llm]
385
+ enabled = false
386
+
387
+ # --- Option A: local Ollama ---
388
+ backend = "ollama"
389
+ ollama_url = "http://localhost:11434"
390
+ ollama_model = "qwen2.5:7b"
391
+
392
+ # --- Option B: OpenAI-compatible (any provider speaking ---
393
+ # --- POST /v1/chat/completions) ---
394
+ # backend = "openai"
395
+ # openai_api_key = "sk-or-..." # OpenRouter key, OpenAI key, etc.
396
+ # openai_base_url = "https://openrouter.ai/api/v1" # <- any vLLM-compatible endpoint
397
+ # openai_model = "meta-llama/llama-3.1-8b-instruct:free"
398
+
399
+ # timeout_seconds = 30.0
400
+ ```
401
+
402
+ Quick CLI:
403
+
404
+ ```powershell
405
+ lorewiki config list # show the resolved config (with defaults)
406
+ lorewiki config get llm.backend # get one key
407
+ lorewiki config set llm.enabled true # set one key (auto-creates the file)
408
+ lorewiki config set llm.openai_base_url '"https://openrouter.ai/api/v1"'
409
+ # Note: when setting a string, quote it as a TOML literal.
410
+ ```
411
+
412
+ > **Note on Azure OpenAI**: the Azure endpoint path is
413
+ > `/openai/deployments/<deployment>/chat/completions?api-version=...`
414
+ > and is **not** currently supported. Use OpenRouter or a
415
+ > self-hosted vLLM-compatible endpoint, or wait for phase-7 Azure
416
+ > support (open an issue if you need it sooner).
417
+
418
+ ### 7. Write to the wiki — pick the right path
419
+
420
+ There are three honest ways to put content into the wiki. Pick the
421
+ smallest one that does the job — over-engineering hurts:
422
+
423
+ | Scenario | Use |
424
+ |------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
425
+ | **One-off note** (1-3 paragraphs, user just told you) | `lorewiki add --title ... --body ...` — writes + auto-indexes in one step. |
426
+ | **A handful of related notes** (≤ ~20) | Run `lorewiki add` a few times in a loop. Don't write a script. |
427
+ | **Bulk scrape** (tens to thousands of files, e.g. scraping | Either (a) drop the directory under the active topic and run `lorewiki index` (one-shot), |
428
+ | a vendor's docs, importing a git repo, etc.) | or (b) write a short Python script that writes `.md` files with frontmatter and call |
429
+ | | `lorewiki index` once at the end. |
430
+ | **Topic bootstrap** (creating a new isolated vault) | `lorewiki topic create <name> [--source <path>]` — the topic system is built for this. |
431
+
432
+ **Auto-judgment rule for the LLM**: if the user pasted a single
433
+ short note, reach for `lorewiki add`. If the user gave you a list,
434
+ a URL, or a directory to capture, reach for a Python script (or
435
+ `lorewiki topic create --source`). Don't run `lorewiki add` in a
436
+ 50-iteration loop — the per-invocation indexing cost adds up. The
437
+ user's language for "store this" / "capture these" / "记一下" /
438
+ "保存这些" / "メモして" / whatever is irrelevant to the rule; the
439
+ trigger is *shape* (paragraph vs list/URL/directory), not language.
440
+
441
+ #### `lorewiki add` quick reference
442
+
443
+ ```powershell
444
+ # Inline body
445
+ lorewiki add `
446
+ --title "Python Design" `
447
+ --module patterns `
448
+ --tag python --tag design `
449
+ --body "Some deep details about Python design pattern."
450
+
451
+ # Pipe from stdin (no --body)
452
+ echo "# Inferred Title`n`nbody text" | lorewiki add --module notes
453
+
454
+ # Bulk scrape: short Python script
455
+ $urls = @("https://vendor.example.com/docs/a", "https://vendor.example.com/docs/b")
456
+ foreach ($u in $urls) {
457
+ $md = (Invoke-WebRequest $u).Content | ./scrape.ps1
458
+ $slug = ($u -split "/" | Select-Object -Last 1) -replace ".html$", ""
459
+ lorewiki add --title $slug --module scraped --body $md --path $wiki
460
+ }
461
+ lorewiki index # only if add was called many times; usually redundant
462
+ ```
463
+
464
+ The file lands at `<wiki>/<module>/<slug>.md`; the slug is derived
465
+ from the title via ASCII-only normalisation (Chinese characters in
466
+ the title are stripped, so "RISC-V 工具链" becomes `risc-v` — see
467
+ [slug caveats](#slug-caveats) below). After a successful write,
468
+ an incremental `build_index` runs so the new doc is immediately
469
+ retrievable. Use `--raw` for machine-readable JSON output,
470
+ `--force` to overwrite an existing file. The path-traversal check
471
+ will reject any `--module` value that resolves outside the wiki
472
+ root.
473
+
474
+ #### Slug caveats
475
+
476
+ The slug is built with `[^a-z0-9]+` → `-`, lowercased, trimmed, then
477
+ capped at 64 chars. **Non-ASCII characters in the title are
478
+ stripped** — so a title like "RISC-V 工具链" produces the slug
479
+ `risc-v` (the CJK portion is gone). The body keeps everything;
480
+ only the filename is ASCII-folded. This affects every non-ASCII
481
+ script uniformly (CJK, Vietnamese, Thai, Korean, Cyrillic, etc.).
482
+ For better filename preservation, the user should pick an
483
+ English title and put the original-language text in `--body`.
484
+
485
+ ## Modes (`--mode` flag for search / configured for ask)
486
+
487
+ | Mode | Best for | Notes |
488
+ | --------- | --------------------------------------------------- | ------------------------------------------- |
489
+ | `mix` | Almost everything (default). | RRF-fused BM25 + hierarchy. Highest recall. |
490
+ | `bm25` | Exact-term / English / code-symbol queries. | FTS5 trigram + LIKE fallback for short CJK. |
491
+ | `hierarchy` | "Show me everything under module X" style queries.| Walks the module tree from matched node. |
492
+ | `vector` | Not implemented yet — silently falls back to `mix`. | Reserved for the phase-6 sqlite-vec layer. |
493
+
494
+ ## Output Discipline
495
+
496
+ - **`search` already defaults to JSON** — no flag needed. Only `ask`,
497
+ `topic list`, `topic suggest`, and `show` have a `--raw` (for those,
498
+ `--raw` is *opt-in* to switch off the human-friendly default).
499
+ - **Cite by `doc_path`** in your final answer (`[api/user/auth.md]`), not by
500
+ internal chunk IDs.
501
+ - If the wiki has nothing relevant (`hits == []`), say so plainly. Do NOT
502
+ hallucinate content the wiki doesn't contain.
503
+ - For `ask` with `used_llm == false`, the returned `answer` already lists
504
+ the chunks — you can pass it through unchanged.
505
+ - **Auto-judge the write path**: prefer `lorewiki add` for 1-3
506
+ paragraphs the user just told you. Drop a directory + `lorewiki index`
507
+ for bulk. **Never** wrap `add` in a 50-iteration loop when a
508
+ short script + one index call would be cleaner.
509
+
510
+ ## Common Pitfalls
511
+
512
+ | Pitfall | Avoidance |
513
+ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
514
+ | Reaching for `lorewiki add` in a tight loop for bulk ingestion. | Drop the source directory and run `lorewiki index` once. Re-indexing every add is O(N) — a single bulk index is O(1). |
515
+ | Searching with a 1-2 character CJK query and getting "0 hits". | Combine with at least one more char or use `--mode bm25` which falls back to LIKE for short queries. |
516
+ | Forgetting `--path` and assuming `cwd` has a wiki config. | Always pass `--path`. If the user is ambiguous, follow the priority chain in [Path Handling Convention](#path-handling-convention). |
517
+ | Editing a `.md` and not re-indexing. | `lorewiki index --path "<WIKI>"` is incremental — unchanged files skip; just run it. |
518
+ | Using `Out-File` / `Set-Content` in PowerShell to write Markdown — may add BOM that breaks frontmatter parsing. | Use the agent's Write / Edit tool, or `[IO.File]::WriteAllText` with UTF-8 no-BOM. |
519
+ | Asking `lorewiki ask` and assuming an LLM answer. | Check the `used_llm` field in `ask --raw` output; fallback is normal and useful. |
520
+ | JSON output appears as `?` / replacement chars on Windows PowerShell. | The CLI forces UTF-8 stdout. If you still see it: (a) upgrade `lorewiki`; (b) as a fallback, prefix the command with `chcp 65001 |` to force the shell code page to UTF-8; (c) parse the prettified terminal panel for the `doc_path` and use the `Read` tool on the source `.md` file. |
521
+ | Brute-force scanning the whole filesystem when the wiki path is unknown. | Use the bounded-depth scan in [Path Handling Convention](#path-handling-convention) (Depth 3 against a small set of plausible roots such as the user's workspace and ``$HOME/Documents``), not ``Get-ChildItem -Recurse`` against an entire drive root. The exact list of roots is platform-specific; pick a small handful that makes sense for the user's machine, never a full recursive scan. |
522
+
523
+ ## Quick Reference
524
+
525
+ ```powershell
526
+ lorewiki --version
527
+ lorewiki init --path "<WIKI>"
528
+ lorewiki index --path "<WIKI>" [--rebuild]
529
+ lorewiki status --path "<WIKI>"
530
+ lorewiki search "<QUERY>" --path "<WIKI>" --mode {mix|bm25|hierarchy} --top-k N # default: JSON
531
+ lorewiki show "<DOC_PATH>" --path "<WIKI>" [--raw] # default: cleaned body
532
+ lorewiki tree "[<PREFIX>]" --path "<WIKI>" [--depth N] # hierarchy view
533
+ lorewiki add --title "<T>" [--module <M>] [--body <B> | --file <F> | stdin] # one-off write + reindex
534
+ lorewiki ask "<QUERY>" --path "<WIKI>" --top-k N --raw # JSON (default: Markdown)
535
+ lorewiki topic {list|use|create|show|...} # vault management
536
+ lorewiki config {list|get|set} ... --path "<WIKI>"
537
+ lorewiki clean --path "<WIKI>" [--dry-run] [--no-backup] # on-disk .md rewrite
538
+ ```
539
+
540
+ ## Decision Cheat-Sheet
541
+
542
+ | User intent | Command |
543
+ | ------------------------------------------------------ | ------------------------------------------------------------- |
544
+ | "look up X in the wiki" | `lorewiki search "X" --mode mix --top-k 5` |
545
+ | "show me the full content of doc Y" | `lorewiki show "<DOC_PATH>"` |
546
+ | "what's in the wiki / what modules exist" | `lorewiki tree` |
547
+ | "does the wiki explain how X is implemented?" | `lorewiki ask "how is X implemented?" --raw` |
548
+ | "what modules does the wiki have?" | `lorewiki tree --depth 2` |
549
+ | "remember this / save this / take a note about X" | `lorewiki add --title "X" --body "..."` |
550
+ | "store these N pages / scrape this site" | Drop the dir under the topic, then `lorewiki index` |
551
+ | "import my entire <some-tool> docs folder" | `lorewiki topic create <name> --source <docs-folder>` |
552
+ | "why does this query return no results?" | Re-run with `--mode bm25` to inspect scores |
@@ -0,0 +1,391 @@
1
+ """Cross-platform skill installer — wheel-internal copy.
2
+
3
+ This module is the **runtime** version of ``skills/install.py``. Both
4
+ modules intentionally implement the same Tool catalog and the same
5
+ ``_parse_choice`` grammar; ``skills/install.py`` is for developers
6
+ who clone the repository, while this one ships inside the wheel so
7
+ that a user who installed ``lorewiki`` from PyPI can run
8
+ ``lorewiki install`` without first cloning the repo.
9
+
10
+ Source of truth for the tool catalog lives here (``TOOLS``). The
11
+ repo-side ``skills/install.py`` is kept independent on purpose:
12
+ ``skills/install.py`` is part of the source checkout / dev workflow
13
+ and tests live in ``tests/test_skill_installer.py``; the wheel
14
+ inherits the same logic at install time without the cross-import
15
+ that would make ``lorewiki install`` depend on the repo tree.
16
+
17
+ Differences from ``skills/install.py``:
18
+
19
+ - The wheel has no notion of "the source tree" — we use
20
+ ``importlib.resources`` to read the bundled ``SKILL.md`` from
21
+ ``lorewiki.data.skill_template``.
22
+ - The wheel install is **copy-only** (no symlink mode) because
23
+ symlink semantics on Windows without Developer Mode are
24
+ unreliable, and the wheel can't ship a Windows-aware symlink
25
+ fallback policy cleanly.
26
+ - No ``--all-known`` / "ignore detection" flag: the wheel-side
27
+ ``install`` is meant to be safe-by-default, only installing where
28
+ the tool's own config root already exists.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import re
34
+ import sys
35
+ from dataclasses import dataclass
36
+ from importlib import resources
37
+ from pathlib import Path
38
+ from typing import Final
39
+
40
+ SKILL_NAME: Final[str] = "lorewiki"
41
+
42
+ # Source of truth: ``lorewiki.data.skill_template.SKILL.md`` is
43
+ # bundled inside the wheel (see ``pyproject.toml`` ``[tool.hatch.build
44
+ # .targets.wheel].include``). The dev-tree ``skills/lorewiki/SKILL.md``
45
+ # is the canonical upstream; the wheel copy must be re-synced when
46
+ # the dev copy changes (a test enforces the two are byte-identical).
47
+ _WHEEL_SKILL_PACKAGE: Final[str] = "lorewiki.data.skill_template"
48
+ _WHEEL_SKILL_FILE: Final[str] = "SKILL.md"
49
+
50
+
51
+ def _read_skill_template() -> str:
52
+ """Read the bundled ``SKILL.md`` from the wheel.
53
+
54
+ Falls back to a hard error if the data file is missing — this
55
+ is a wheel-build configuration mistake (the package data was
56
+ not included in ``pyproject.toml``).
57
+ """
58
+ try:
59
+ return resources.files(_WHEEL_SKILL_PACKAGE).joinpath(
60
+ _WHEEL_SKILL_FILE
61
+ ).read_text(encoding="utf-8")
62
+ except (ModuleNotFoundError, FileNotFoundError) as exc:
63
+ msg = (
64
+ f"bundled skill template not found: "
65
+ f"{_WHEEL_SKILL_PACKAGE}/{_WHEEL_SKILL_FILE} ({exc}). "
66
+ "This is a wheel-build error — check pyproject.toml's "
67
+ "[tool.hatch.build.targets.wheel].include list."
68
+ )
69
+ raise FileNotFoundError(msg) from exc
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Tool catalog
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Tool:
79
+ """One supported AI tool's install target and aliases."""
80
+
81
+ id: str
82
+ label: str
83
+ primary: str
84
+ aliases: tuple[str, ...] = ()
85
+
86
+ def resolve(self, path_template: str) -> Path:
87
+ """Expand ``<name>`` + ``$HOME`` / ``~`` / ``$XDG_*`` env vars."""
88
+ s = path_template.replace("<name>", SKILL_NAME)
89
+ s = os.path.expandvars(s)
90
+ s = os.path.expanduser(s)
91
+ return Path(s).resolve()
92
+
93
+
94
+ # Mirrors the catalog in ``skills/install.py``. If you edit one,
95
+ # edit the other — ``tests/test_skill_installer_wheel.py`` enforces
96
+ # equality of the visible-id set.
97
+ #
98
+ # ``primary`` is kept as a *raw* path template with ``$VAR`` placeholders
99
+ # rather than ``os.path.expandvars(os.environ.get(...))`` evaluated at
100
+ # module-import time. ``Tool.resolve`` runs ``os.path.expandvars`` on
101
+ # every call, so the env is read fresh — important for tests that
102
+ # monkey-patch ``XDG_CONFIG_HOME`` *after* the module is imported
103
+ # (and for runners that have a non-default env we want to honour
104
+ # *now*, not at import time).
105
+ TOOLS: Final[tuple[Tool, ...]] = (
106
+ Tool(
107
+ id="opencode",
108
+ label="opencode",
109
+ primary="$XDG_CONFIG_HOME/opencode/skills/<name>",
110
+ ),
111
+ Tool(
112
+ id="claude",
113
+ label="Claude Code",
114
+ primary="~/.claude/skills/<name>",
115
+ ),
116
+ Tool(
117
+ id="codex",
118
+ label="Codex CLI",
119
+ primary="$CODEX_HOME/skills/<name>",
120
+ ),
121
+ Tool(
122
+ id="cursor",
123
+ label="Cursor",
124
+ primary="~/.cursor/skills/<name>",
125
+ # Cursor also auto-discovers ~/.agents/skills/ (interop path).
126
+ aliases=("~/.agents/skills/<name>",),
127
+ ),
128
+ Tool(
129
+ id="gemini",
130
+ label="Gemini CLI",
131
+ primary="$GEMINI_HOME/skills/<name>",
132
+ aliases=("~/.agents/skills/<name>",),
133
+ ),
134
+ Tool(
135
+ id="antigravity",
136
+ label="Google Antigravity",
137
+ primary="~/.gemini/antigravity/skills/<name>",
138
+ ),
139
+ )
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Detection
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ def _parent_exists(path: Path) -> bool:
148
+ """True if the tool's *config root* exists (two levels above the skill dir).
149
+
150
+ For ``~/.config/opencode/skills/lorewiki`` we check
151
+ ``~/.config/opencode/`` — the directory the tool itself creates
152
+ on first launch. Going further up would land on ``$HOME`` /
153
+ ``C:\\`` which always exist and would mark every tool as
154
+ installed.
155
+ """
156
+ return path.parent.parent.exists()
157
+
158
+
159
+ def detect_installed_tools() -> list[Tool]:
160
+ """Return the subset of ``TOOLS`` whose skills dir is plausibly reachable."""
161
+ return [t for t in TOOLS if _parent_exists(t.resolve(t.primary))]
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Interactive prompt parser (multi-select, ranges, ``a``, ``q``)
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def _parse_choice(raw: str, max_n: int) -> list[int] | str | None:
170
+ """Parse the interactive ``install into which?`` answer.
171
+
172
+ Returns one of:
173
+
174
+ - ``list[int]`` — 1-based indices of the chosen tools, in ascending
175
+ order with duplicates removed.
176
+ - ``"all"`` — install every detected tool.
177
+ - ``"quit"`` — user wants to exit without installing.
178
+ - ``None`` — input is invalid; caller should error out.
179
+
180
+ Accepted syntax (case-insensitive, whitespace stripped):
181
+
182
+ - ``""`` / ``q`` / ``quit`` → ``"quit"``
183
+ - ``a`` / ``all`` → ``"all"``
184
+ - ``3`` → ``[3]``
185
+ - ``1,3,5`` or ``1 3 5`` → ``[1, 3, 5]``
186
+ - ``2-4`` → ``[2, 3, 4]``
187
+ - ``1,3-5,6`` (mixed) → ``[1, 3, 4, 5, 6]``
188
+
189
+ Out-of-range numbers (anything outside ``1..max_n``), empty
190
+ selections, malformed tokens, and reversed ranges all return
191
+ ``None``.
192
+ """
193
+ c = raw.strip().lower()
194
+ if c in ("", "q", "quit"):
195
+ return "quit"
196
+ if c in ("a", "all"):
197
+ return "all"
198
+ result: set[int] = set()
199
+ for token in re.split(r"[,\s]+", c):
200
+ if not token:
201
+ continue
202
+ m = re.fullmatch(r"(\d+)(?:-(\d+))?", token)
203
+ if m is None:
204
+ return None
205
+ start = int(m.group(1))
206
+ end = int(m.group(2)) if m.group(2) else start
207
+ if start < 1 or end < 1 or start > max_n or end > max_n or start > end:
208
+ return None
209
+ result.update(range(start, end + 1))
210
+ return sorted(result) if result else None
211
+
212
+
213
+ def prompt_for_targets(detected: list[Tool], input_fn=input) -> list[Tool] | None:
214
+ """Interactive multi-select prompt.
215
+
216
+ Returns the chosen tools, or ``None`` if the user quits /
217
+ provides invalid input. ``input_fn`` is injectable for tests.
218
+ """
219
+ if not detected:
220
+ print("no AI tools detected on this machine.", file=sys.stderr)
221
+ return None
222
+ print("Detected tools on this machine:")
223
+ for i, tool in enumerate(detected, 1):
224
+ print(f" {i}. {tool.label} -> {tool.resolve(tool.primary)}")
225
+ print(" a. all of the above")
226
+ print(" q. quit")
227
+ choice = input_fn(
228
+ "install into which? [a / 1 / 1,3,5 / 1 3 5 / 2-4 / q]: "
229
+ )
230
+ parsed = _parse_choice(choice, len(detected))
231
+ if parsed == "quit":
232
+ return None
233
+ if parsed == "all":
234
+ return detected
235
+ if parsed is None:
236
+ print("invalid choice", file=sys.stderr)
237
+ return None
238
+ assert isinstance(parsed, list)
239
+ return [detected[i - 1] for i in parsed]
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Install / uninstall primitives
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ def install_skill(
248
+ tool: Tool,
249
+ *,
250
+ skill_text: str | None = None,
251
+ overwrite: bool = False,
252
+ ) -> list[str]:
253
+ """Copy the bundled ``SKILL.md`` to the tool's primary path (and aliases).
254
+
255
+ Returns the human-readable action lines (suitable for printing
256
+ directly to the user). ``overwrite=True`` re-writes an existing
257
+ target; the default is to refuse and emit a ``[skip]`` line so
258
+ the caller can surface a friendly hint.
259
+ """
260
+ if skill_text is None:
261
+ skill_text = _read_skill_template()
262
+ primary = tool.resolve(tool.primary)
263
+ primary.parent.mkdir(parents=True, exist_ok=True)
264
+ actions: list[str] = []
265
+ if primary.exists() and not overwrite:
266
+ actions.append(f"[skip] {primary} (exists; pass --force to overwrite)")
267
+ else:
268
+ primary.write_text(skill_text, encoding="utf-8")
269
+ actions.append(f"[ok] wrote {primary}")
270
+ for alias_tmpl in tool.aliases:
271
+ alias = tool.resolve(alias_tmpl)
272
+ if alias == primary:
273
+ continue
274
+ alias.parent.mkdir(parents=True, exist_ok=True)
275
+ if alias.exists() and not overwrite:
276
+ actions.append(
277
+ f"[skip] {alias} (alias; exists; pass --force to overwrite)"
278
+ )
279
+ else:
280
+ alias.write_text(skill_text, encoding="utf-8")
281
+ actions.append(f"[ok] wrote {alias} (alias)")
282
+ return actions
283
+
284
+
285
+ def uninstall_skill(tool: Tool) -> list[str]:
286
+ """Remove the skill from the tool's primary path and aliases."""
287
+ actions: list[str] = []
288
+ for tmpl in (tool.primary, *tool.aliases):
289
+ target = tool.resolve(tmpl)
290
+ if target.exists() or target.is_symlink():
291
+ target.unlink()
292
+ actions.append(f"[rm] {target}")
293
+ else:
294
+ actions.append(f"[skip] {target} (not present)")
295
+ return actions
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # Status report (one-liner per tool, ``[x]`` / ``[ ]``)
300
+ # ---------------------------------------------------------------------------
301
+
302
+
303
+ def _skill_installed(tool: Tool) -> bool:
304
+ """True if the tool's primary OR any alias already has the skill."""
305
+ return any(tool.resolve(tmpl).exists() for tmpl in (tool.primary, *tool.aliases))
306
+
307
+
308
+ def status_report() -> str:
309
+ """Return a human-readable status block for ``lorewiki install --status``."""
310
+ lines = ["LoreWiki skill status", "=" * 60]
311
+ for tool in TOOLS:
312
+ primary = tool.resolve(tool.primary)
313
+ marker = "[x]" if _skill_installed(tool) else "[ ]"
314
+ lines.append(f" {marker} {tool.label:<18} {primary}")
315
+ for alias_tmpl in tool.aliases:
316
+ alias = tool.resolve(alias_tmpl)
317
+ a_marker = "[x]" if alias.exists() else "[ ]"
318
+ lines.append(f" {a_marker} alias {alias}")
319
+ # Hint if the bundled template is readable from the wheel — this
320
+ # is also a soft smoke test of the package-data wiring.
321
+ try:
322
+ _read_skill_template()
323
+ except FileNotFoundError as exc:
324
+ lines.append("")
325
+ lines.append(f" ERROR: {exc}")
326
+ return "\n".join(lines)
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # High-level entry point used by the CLI
331
+ # ---------------------------------------------------------------------------
332
+
333
+
334
+ def run(
335
+ *,
336
+ tool_ids: list[str] | None = None,
337
+ install_all: bool = False,
338
+ uninstall: bool = False,
339
+ force: bool = False,
340
+ show_status: bool = False,
341
+ input_fn=input,
342
+ ) -> int:
343
+ """Single entry point used by ``lorewiki install``.
344
+
345
+ ``tool_ids`` is a list of canonical Tool.id values (e.g.
346
+ ``["opencode", "claude"]``). When ``None`` and ``install_all``
347
+ is ``False``, the function falls back to the interactive
348
+ prompt. Returns the process exit code.
349
+ """
350
+ if show_status:
351
+ print(status_report())
352
+ return 0
353
+
354
+ if tool_ids is not None:
355
+ by_id = {t.id: t for t in TOOLS}
356
+ unknown = [i for i in tool_ids if i not in by_id]
357
+ if unknown:
358
+ print(
359
+ f"unknown --tool ids: {unknown}; "
360
+ f"valid: {sorted(by_id)}",
361
+ file=sys.stderr,
362
+ )
363
+ return 2
364
+ targets: list[Tool] = [by_id[i] for i in tool_ids]
365
+ elif install_all:
366
+ targets = detect_installed_tools()
367
+ if not targets:
368
+ print("no AI tools detected on this machine.", file=sys.stderr)
369
+ return 1
370
+ else:
371
+ targets = prompt_for_targets(detect_installed_tools(), input_fn=input_fn) or []
372
+ if not targets:
373
+ return 0
374
+
375
+ try:
376
+ skill_text = _read_skill_template()
377
+ except FileNotFoundError as exc:
378
+ print(f"error: {exc}", file=sys.stderr)
379
+ return 1
380
+
381
+ print(f"source: bundled {_WHEEL_SKILL_PACKAGE}/{_WHEEL_SKILL_FILE}")
382
+ for tool in targets:
383
+ print(f"\n[{tool.label}]")
384
+ actions = (
385
+ uninstall_skill(tool)
386
+ if uninstall
387
+ else install_skill(tool, skill_text=skill_text, overwrite=force)
388
+ )
389
+ for line in actions:
390
+ print(line)
391
+ return 0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lorewiki"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  description = "Local-first knowledge base for LLM-assisted coding, with hybrid retrieval (BM25 + hierarchy + optional vector) over SQLite FTS5."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -65,7 +65,14 @@ Source = "https://github.com/JochenYang/Lore-wiki"
65
65
  packages = ["lorewiki"]
66
66
  # ship the PEP 561 marker so downstream type checkers know we
67
67
  # publish inline type hints.
68
- include = ["lorewiki/py.typed"]
68
+ # Bundle the LoreWiki agent skill (SKILL.md) as package data so a
69
+ # PyPI user can run ``lorewiki install`` without cloning the repo
70
+ # (see ``lorewiki.utils.skill_installer`` and
71
+ # ``lorewiki.cli.install_cmd``).
72
+ include = [
73
+ "lorewiki/py.typed",
74
+ "lorewiki/data/skill_template/SKILL.md",
75
+ ]
69
76
  exclude = [
70
77
  "tests/",
71
78
  "example_wiki/",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes