Wiki
A wiki is a structured, indexed knowledge base organized as a folder tree with
_index.md files. Each folder has an index that links to its children
(subfolders and pages), and a content section below a *** delimiter for
user-authored notes.
Initialize a wiki in the current project and configure integrations:
wiki init — scaffold a new wiki with a root index
wiki config — install Obsidian plugins and the git merge driver
wiki trust — authorize a wiki to run its .wiki/wiki.py hook
Maintain indexes as files are added and removed:
wiki lint — validate structure and flag issues
wiki update — sync index links with the filesystem
wiki new — create an indexed folder with an authored desc and content
Browse structure, search across content, and read entries:
wiki map — print an indented tree overview
wiki search — rank relevant pages with SQLite FTS5
wiki match — match content with regex
wiki read — read a named entry
Usage
Install the CLI from PyPI if it is not already on your PATH:
pipx install plasma-wiki
(pip install or uv tool install work too.)
Then run commands directly:
wiki <command> ...
Run wiki --help for a list of commands, and wiki <command> --help for full
option descriptions.
Working at scale
A wiki is many small, independent pages, so wiki work parallelizes well and is
often too large for one context. Default to sub-agents and dynamic workflows
rather than authoring or auditing page by page yourself:
- Fan out sub-agents. When seeding or expanding a wiki, give each
independent page — or each source to research and digest — to its own
sub-agent, then run
wiki update once to stitch the new pages into the
indexes. Update adds and repairs index link rows and frontmatter only — it
never linkifies mentions in page prose, so author [[...]] cross-links by
hand.
- Drive sweeps with a dynamic workflow. When auditing, relinking, or
restructuring an existing wiki, pipeline its pages through a workflow so each
is read, revised, and verified on its own — slow pages never block fast ones.
Conventions
.wiki/ is the tool's namespace. Every root carries a .wiki/ directory
holding settings.json — the file that declares the wiki root; wiki init
writes it and wiki update restores a missing one — plus the derived
word-counts and ranked-search caches and the staged Obsidian config. Never
author content there; the walk skips dot-directories by construction.
- Exclusions are configurable. Beyond the built-ins (dot-paths, symlinks,
_index.md), gitignore-style globs in exclude.patterns in
.wiki/settings.json exclude whole subtrees from indexing — never walked,
scaffolded, or linted, though wiki read still serves them. The enclosing git
repository's ignore rules fence the same way, with no configuration: what the
repo ignores is not wiki content, so a stray driver output beside tracked
pages is never adopted, minted an _index.md, or linked — delete or fence
residue rather than letting update sweep it in. A wiki whose own root is
gitignored is exempt.
- Name validation is configurable. By default the wiki rejects only
structural characters (
/, \, *, [, ], |, #), a leading dot,
non-printable names, and the reserved _index stem — spaces, dashes, and
unicode all pass. Stricter rules (e.g. ASCII identifiers) are opt-in per wiki
via naming.validate in .wiki/settings.json (seed it at creation with
wiki init --settings); wiki init and wiki lint enforce whatever policy
is set.
- Timestamps are tool-owned and configurable.
wiki update writes both
stamps when a file gains frontmatter, keeps created: from then on, and
rewrites updated: on every actual write — never hand-edit them; an edit goes
undetected unless the value stops parsing under the configured format, which
wiki lint fails. created/updated default to UTC in %Y-%m-%dT%H:%M:%SZ;
set timestamp.timezone (an IANA name) and timestamp.format (a strftime
string) in .wiki/settings.json to change them — use %z rather than a
literal Z for a non-UTC zone.
- Names are path-derived; titles are authored.
wiki update sets each
page's name and H1 heading to the path-joined name (e.g. core/design) so
names stay consistent with the tree structure — to rename an entry, move its
file rather than editing name:. Any index or page may carry an optional
authored title: frontmatter field, which wins its H1 (wiki update keeps
the line directly under name:, and adding frontmatter to a bare page seeds
title: from its authored H1); without one, a hand-edited heading is still
rewritten to name. Unset a title by deleting the line or setting
title: null — update removes it, and lowercase null is the only reset
spelling (~/Null/NULL render literally as the heading). Keep titles on a
single line, quote a title containing : , and prefer plain text.
wiki match --field title matches only authored titles — an unset entry has
no line to match. Setting titles.required to true in .wiki/settings.json
demands a title everywhere: update seeds a title: null placeholder on every
index and page missing one, and lint fails each placeholder until a value is
authored.
- Categories are authored and optional. An index or page may carry a
category: frontmatter field; wiki update copies it into the parent index's
link label as a [category] name prefix, and wiki map --category filters by
it. Fresh frontmatter carries no category: line — unset one by deleting the
line or setting category: null (update removes the line; as with titles,
lowercase null is the only reset spelling). Keep categories on a single
line.
- Frontmatter order is tool-enforced.
wiki update keeps every block in
canonical order — name, title, desc, category, tags, sources,
created, updated — moving each field (with its block-scalar body) verbatim
into its slot. Custom keys are allowed: they keep their relative order below
the known fields, above the timestamps.
- Wikilinks stay inside the wiki. A wikilink (
[[...]]) must target another
page in the same wiki. Files outside the wiki (source files, configs, another
wiki's pages) can be referenced by name or in backticks, but never linked.
- Lint's output contract.
wiki lint prints issues to stdout and soft notes
to stderr; exit 1 means exactly "issues found" (0 clean, 2 a command error) —
notes never gate. A script must branch on the exit code or read
wiki lint --json (one JSON document on stdout carrying every finding typed:
an explicit issue/note severity, a machine kind, and per-kind payload
fields beside the prose text), never classify findings by scraping the prose
streams: a stderr note is not a blocking issue.
- Stale wikilinks are soft notes. A
[[...]] in index or page prose whose
target no longer exists draws a stderr note from wiki lint without failing
the run. Broken links in the generated index link block — the rows
wiki update maintains — are hard issues until the next update prunes them
(each removal announced, with the cause named when the target is merely
excluded rather than deleted), as is a prose wikilink naming a folder rather
than the folder's index page: link [[folder/_index]], never [[folder]].
- Descriptions end in a period.
wiki lint fails a desc (or an authored
link description) that lacks a trailing period; the seeded ... placeholder
only draws a soft note. Author the desc in the child page's frontmatter —
wiki update copies it onto the parent index's link line. A desc containing
: must be YAML-quoted; surrounding quotes are stripped when the value is
read. Never hand-wrap a desc mid-word or onto a list-marker start — let the
block scalar carry the breaks; lint fails the wrap artifacts (a hyphen dangle,
a phantom list item).
- Fill in auto-created index descs.
wiki update creates a missing
_index.md for every new directory with a desc: ... placeholder and
announces the batch in its condensed summary
(Created N new indexes (fill in their descs); run with --full for the
per-path New index: lines). Fill in the desc right after the update — lint
soft-notes the placeholder until you do. For a deliberate creation (an
adoption ceremony's mechanical step), prefer
wiki new <folder> --desc ... --content ...: it requires both authored inputs
— refusing blanks and placeholders outright, descs are never auto-stubbed —
and wires the folder's rows and the parent's new row in the same pass, so the
adoption lands lint-complete. The wiring is a scoped wiki update of the
parent subtree (the whole wiki for a top-level folder), so pending maintenance
in that scope — adoptions, prunes — lands in the same run.
- Bare pages are adopted loudly. A page with no frontmatter gains it on the
next
wiki update — with title: seeded from its authored H1, while a page
with no H1 gains the path-joined heading in its body, never a seeded title —
and each adoption is announced (Adopted N bare pages (frontmatter added) in
the condensed summary; --full prints the per-page lines). Until then
wiki lint names the page as a hard issue
(Bare page (no frontmatter); update will adopt it) alongside the adoption
diff.
- Suppress lint locally with a
no-lint region. A page that must display
otherwise-flagged content (sample conflict markers, stale link examples) wraps
those lines in <!-- start: no-lint --> ... <!-- end: no-lint -->, which
silences the positional rules — hard issues and soft notes alike — for just
that span. Regions never affect file-level checks, and a dangling or nested
marker is itself a hard lint issue.
- Give markdown formatters the wiki plugin. The
*** delimiter and
[[wikilinks]] are load-bearing syntax; mdformat/prettier-style hooks rewrite
*** to --- and escape the brackets, demoting the generated link block to
plain text. wiki update repairs a mangled index and wiki lint names the
damage signatures (escaped wikilinks, a thematic break standing where ***
belongs), but don't rely on the repair: for mdformat add the mdformat-wiki
plugin (under pre-commit, additional_dependencies: [mdformat-wiki] on the
hook, dropping a coexisting mdformat-frontmatter — both register a
frontmatter renderer and whichever is discovered first wins), which makes wiki
faces round-trip byte-identically; for formatters with no plugin lane (e.g.
prettier) exclude the wiki root instead (wiki/ in .prettierignore).
- The git merge driver resolves only the generated region. For
_index.md
files it normalizes the regenerated name/updated keys to ours (plus
created on an add/add merge, where both sides seeded it), resolves the link
block to the union of both sides' rows — ours' layout wins and rows present
only in theirs ride over with their desc continuations, appended above the
closing ***, so a merge never drops one side's additions — and three-way
merges everything authored — the remaining frontmatter fields
(title/desc/created/category/tags/sources) and the user content
below *** — which can still conflict for hand-resolution. A side missing its
*** entirely (formatter damage) can't be split into regions, so it conflicts
whole-file with a hint comment naming the repair — restore the *** on that
branch (wiki update does it), then redo the merge. Run wiki update after a
merge to re-sort the link rows and prune any carried row whose target is gone
from the merged filesystem — the H1 rides ours' link-block layout, so a
merged-in title: shows in its H1 only after that update. init/config
register the driver in local git config and write the **/_index.md glob to
.gitattributes in the working tree only — you stage and commit it yourself,
and each clone runs wiki config once to register the driver.
- Leave new-directory index bodies empty during concurrent work. When
sibling branches both create the same new directory, its two
_index.mds
merge add/add with no common ancestor: the generated region resolves
automatically — including the seeded created stamps, which are wiki update
churn on both sides — but body prose authored below *** on both sides
conflicts for hand-union (empty or identical bodies merge clean). Concurrent
cohorts should leave a new directory's index body empty until after the merge
wave, then author it once. The merge driver plants a one-line HTML-comment
hint above such add/add conflict markers naming this convention — delete it as
you resolve.
- A
.wiki/wiki.py hook needs explicit trust. A wiki may ship a
.wiki/wiki.py (a custom Wiki subclass) that runs code with the user's
privileges, so wiki refuses to load an untrusted hook — every command that
resolves the wiki fails, naming the hook and pointing at wiki trust. This is
a security decision for the human: surface the error and let the user run
wiki trust for a wiki they have vetted, rather than running it yourself or
working around the refusal. A hookless wiki needs no trust; trust is recorded
per resolved root in ~/.wiki/settings.json (WIKI_CONFIG_DIR overrides the
config home).
1---2name: wiki3description: Indexed knowledge bases with command-line tools for agents.4---56# Wiki78A wiki is a structured, indexed knowledge base organized as a folder tree with9`_index.md` files. Each folder has an index that links to its children10(subfolders and pages), and a content section below a `***` delimiter for11user-authored notes.1213Initialize a wiki in the current project and configure integrations:1415- `wiki init` — scaffold a new wiki with a root index16- `wiki config` — install Obsidian plugins and the git merge driver17- `wiki trust` — authorize a wiki to run its `.wiki/wiki.py` hook1819Maintain indexes as files are added and removed:2021- `wiki lint` — validate structure and flag issues22- `wiki update` — sync index links with the filesystem23- `wiki new` — create an indexed folder with an authored desc and content2425Browse structure, search across content, and read entries:2627- `wiki map` — print an indented tree overview28- `wiki search` — rank relevant pages with SQLite FTS529- `wiki match` — match content with regex30- `wiki read` — read a named entry3132## Usage3334Install the CLI from PyPI if it is not already on your `PATH`:3536```bash37pipx install plasma-wiki38```3940(`pip install` or `uv tool install` work too.)4142Then run commands directly:4344```bash45wiki <command> ...46```4748Run `wiki --help` for a list of commands, and `wiki <command> --help` for full49option descriptions.5051## Working at scale5253A wiki is many small, independent pages, so wiki work parallelizes well and is54often too large for one context. Default to sub-agents and dynamic workflows55rather than authoring or auditing page by page yourself:5657- **Fan out sub-agents.** When seeding or expanding a wiki, give each58 independent page — or each source to research and digest — to its own59 sub-agent, then run `wiki update` once to stitch the new pages into the60 indexes. Update adds and repairs index link rows and frontmatter only — it61 never linkifies mentions in page prose, so author `[[...]]` cross-links by62 hand.63- **Drive sweeps with a dynamic workflow.** When auditing, relinking, or64 restructuring an existing wiki, pipeline its pages through a workflow so each65 is read, revised, and verified on its own — slow pages never block fast ones.6667## Conventions6869- **`.wiki/` is the tool's namespace.** Every root carries a `.wiki/` directory70 holding `settings.json` — the file that declares the wiki root; `wiki init`71 writes it and `wiki update` restores a missing one — plus the derived72 word-counts and ranked-search caches and the staged Obsidian config. Never73 author content there; the walk skips dot-directories by construction.74- **Exclusions are configurable.** Beyond the built-ins (dot-paths, symlinks,75 `_index.md`), gitignore-style globs in `exclude.patterns` in76 `.wiki/settings.json` exclude whole subtrees from indexing — never walked,77 scaffolded, or linted, though `wiki read` still serves them. The enclosing git78 repository's ignore rules fence the same way, with no configuration: what the79 repo ignores is not wiki content, so a stray driver output beside tracked80 pages is never adopted, minted an `_index.md`, or linked — delete or fence81 residue rather than letting update sweep it in. A wiki whose own root is82 gitignored is exempt.83- **Name validation is configurable.** By default the wiki rejects only84 structural characters (`/`, `\`, `*`, `[`, `]`, `|`, `#`), a leading dot,85 non-printable names, and the reserved `_index` stem — spaces, dashes, and86 unicode all pass. Stricter rules (e.g. ASCII identifiers) are opt-in per wiki87 via `naming.validate` in `.wiki/settings.json` (seed it at creation with88 `wiki init --settings`); `wiki init` and `wiki lint` enforce whatever policy89 is set.90- **Timestamps are tool-owned and configurable.** `wiki update` writes both91 stamps when a file gains frontmatter, keeps `created:` from then on, and92 rewrites `updated:` on every actual write — never hand-edit them; an edit goes93 undetected unless the value stops parsing under the configured format, which94 `wiki lint` fails. `created`/`updated` default to UTC in `%Y-%m-%dT%H:%M:%SZ`;95 set `timestamp.timezone` (an IANA name) and `timestamp.format` (a strftime96 string) in `.wiki/settings.json` to change them — use `%z` rather than a97 literal `Z` for a non-UTC zone.98- **Names are path-derived; titles are authored.** `wiki update` sets each99 page's `name` and H1 heading to the path-joined name (e.g. `core/design`) so100 names stay consistent with the tree structure — to rename an entry, move its101 file rather than editing `name:`. Any index or page may carry an optional102 authored `title:` frontmatter field, which wins its H1 (`wiki update` keeps103 the line directly under `name:`, and adding frontmatter to a bare page seeds104 `title:` from its authored H1); without one, a hand-edited heading is still105 rewritten to `name`. Unset a title by deleting the line or setting106 `title: null` — update removes it, and lowercase `null` is the only reset107 spelling (`~`/`Null`/`NULL` render literally as the heading). Keep titles on a108 single line, quote a title containing `: `, and prefer plain text.109 `wiki match --field title` matches only authored titles — an unset entry has110 no line to match. Setting `titles.required` to true in `.wiki/settings.json`111 demands a title everywhere: update seeds a `title: null` placeholder on every112 index and page missing one, and lint fails each placeholder until a value is113 authored.114- **Categories are authored and optional.** An index or page may carry a115 `category:` frontmatter field; `wiki update` copies it into the parent index's116 link label as a `[category] name` prefix, and `wiki map --category` filters by117 it. Fresh frontmatter carries no `category:` line — unset one by deleting the118 line or setting `category: null` (update removes the line; as with titles,119 lowercase `null` is the only reset spelling). Keep categories on a single120 line.121- **Frontmatter order is tool-enforced.** `wiki update` keeps every block in122 canonical order — `name`, `title`, `desc`, `category`, `tags`, `sources`,123 `created`, `updated` — moving each field (with its block-scalar body) verbatim124 into its slot. Custom keys are allowed: they keep their relative order below125 the known fields, above the timestamps.126- **Wikilinks stay inside the wiki.** A wikilink (`[[...]]`) must target another127 page in the same wiki. Files outside the wiki (source files, configs, another128 wiki's pages) can be referenced by name or in backticks, but never linked.129- **Lint's output contract.** `wiki lint` prints issues to stdout and soft notes130 to stderr; exit 1 means exactly "issues found" (0 clean, 2 a command error) —131 notes never gate. A script must branch on the exit code or read132 `wiki lint --json` (one JSON document on stdout carrying every finding typed:133 an explicit `issue`/`note` severity, a machine `kind`, and per-kind payload134 fields beside the prose `text`), never classify findings by scraping the prose135 streams: a stderr note is not a blocking issue.136- **Stale wikilinks are soft notes.** A `[[...]]` in index or page prose whose137 target no longer exists draws a stderr note from `wiki lint` without failing138 the run. Broken links in the generated index link block — the rows139 `wiki update` maintains — are hard issues until the next update prunes them140 (each removal announced, with the cause named when the target is merely141 excluded rather than deleted), as is a prose wikilink naming a folder rather142 than the folder's index page: link `[[folder/_index]]`, never `[[folder]]`.143- **Descriptions end in a period.** `wiki lint` fails a `desc` (or an authored144 link description) that lacks a trailing period; the seeded `...` placeholder145 only draws a soft note. Author the desc in the child page's frontmatter —146 `wiki update` copies it onto the parent index's link line. A desc containing147 `: ` must be YAML-quoted; surrounding quotes are stripped when the value is148 read. Never hand-wrap a desc mid-word or onto a list-marker start — let the149 block scalar carry the breaks; lint fails the wrap artifacts (a hyphen dangle,150 a phantom list item).151- **Fill in auto-created index descs.** `wiki update` creates a missing152 `_index.md` for every new directory with a `desc: ...` placeholder and153 announces the batch in its condensed summary154 (`Created N new indexes (fill in their descs)`; run with `--full` for the155 per-path `New index:` lines). Fill in the desc right after the update — lint156 soft-notes the placeholder until you do. For a deliberate creation (an157 adoption ceremony's mechanical step), prefer158 `wiki new <folder> --desc ... --content ...`: it requires both authored inputs159 — refusing blanks and placeholders outright, descs are never auto-stubbed —160 and wires the folder's rows and the parent's new row in the same pass, so the161 adoption lands lint-complete. The wiring is a scoped `wiki update` of the162 parent subtree (the whole wiki for a top-level folder), so pending maintenance163 in that scope — adoptions, prunes — lands in the same run.164- **Bare pages are adopted loudly.** A page with no frontmatter gains it on the165 next `wiki update` — with `title:` seeded from its authored H1, while a page166 with no H1 gains the path-joined heading in its body, never a seeded title —167 and each adoption is announced (`Adopted N bare pages (frontmatter added)` in168 the condensed summary; `--full` prints the per-page lines). Until then169 `wiki lint` names the page as a hard issue170 (`Bare page (no frontmatter); update will adopt it`) alongside the adoption171 diff.172- **Suppress lint locally with a `no-lint` region.** A page that must display173 otherwise-flagged content (sample conflict markers, stale link examples) wraps174 those lines in `<!-- start: no-lint -->` ... `<!-- end: no-lint -->`, which175 silences the positional rules — hard issues and soft notes alike — for just176 that span. Regions never affect file-level checks, and a dangling or nested177 marker is itself a hard lint issue.178- **Give markdown formatters the wiki plugin.** The `***` delimiter and179 `[[wikilinks]]` are load-bearing syntax; mdformat/prettier-style hooks rewrite180 `***` to `---` and escape the brackets, demoting the generated link block to181 plain text. `wiki update` repairs a mangled index and `wiki lint` names the182 damage signatures (escaped wikilinks, a thematic break standing where `***`183 belongs), but don't rely on the repair: for mdformat add the `mdformat-wiki`184 plugin (under pre-commit, `additional_dependencies: [mdformat-wiki]` on the185 hook, dropping a coexisting `mdformat-frontmatter` — both register a186 frontmatter renderer and whichever is discovered first wins), which makes wiki187 faces round-trip byte-identically; for formatters with no plugin lane (e.g.188 prettier) exclude the wiki root instead (`wiki/` in `.prettierignore`).189- **The git merge driver resolves only the generated region.** For `_index.md`190 files it normalizes the regenerated `name`/`updated` keys to *ours* (plus191 `created` on an add/add merge, where both sides seeded it), resolves the link192 block to the union of both sides' rows — ours' layout wins and rows present193 only in theirs ride over with their desc continuations, appended above the194 closing `***`, so a merge never drops one side's additions — and three-way195 merges everything authored — the remaining frontmatter fields196 (`title`/`desc`/`created`/`category`/`tags`/`sources`) and the user content197 below `***` — which can still conflict for hand-resolution. A side missing its198 `***` entirely (formatter damage) can't be split into regions, so it conflicts199 whole-file with a hint comment naming the repair — restore the `***` on that200 branch (`wiki update` does it), then redo the merge. Run `wiki update` after a201 merge to re-sort the link rows and prune any carried row whose target is gone202 from the merged filesystem — the H1 rides ours' link-block layout, so a203 merged-in `title:` shows in its H1 only after that update. `init`/`config`204 register the driver in local git config and write the `**/_index.md` glob to205 `.gitattributes` in the working tree only — you stage and commit it yourself,206 and each clone runs `wiki config` once to register the driver.207- **Leave new-directory index bodies empty during concurrent work.** When208 sibling branches both create the same new directory, its two `_index.md`s209 merge add/add with no common ancestor: the generated region resolves210 automatically — including the seeded `created` stamps, which are `wiki update`211 churn on both sides — but body prose authored below `***` on both sides212 conflicts for hand-union (empty or identical bodies merge clean). Concurrent213 cohorts should leave a new directory's index body empty until after the merge214 wave, then author it once. The merge driver plants a one-line HTML-comment215 hint above such add/add conflict markers naming this convention — delete it as216 you resolve.217- **A `.wiki/wiki.py` hook needs explicit trust.** A wiki may ship a218 `.wiki/wiki.py` (a custom `Wiki` subclass) that runs code with the user's219 privileges, so `wiki` refuses to load an untrusted hook — every command that220 resolves the wiki fails, naming the hook and pointing at `wiki trust`. This is221 a security decision for the human: surface the error and let the user run222 `wiki trust` for a wiki they have vetted, rather than running it yourself or223 working around the refusal. A hookless wiki needs no trust; trust is recorded224 per resolved root in `~/.wiki/settings.json` (`WIKI_CONFIG_DIR` overrides the225 config home).