Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions docs/manual-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ type: manpage
section: 3 # 1 | 3 | 5 | 7 | 8
name: write-note # page name without section suffix
summary: create or overwrite a markdown note in the knowledge base
generated: hand # hand | registry | typer (regeneration ownership)
generated: hand # hand | registry | cli (regeneration ownership)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the cli owner to the shipped Manpage schema

The documentation now declares cli as a valid ownership token and all eight regenerated section-1 pages use it, but plugins/claude-code/schemas/manpage.md:18 still permits only [registry, typer, hand]. A user following this document and copying the shipped opt-in schema will therefore get an enum-mismatch warning when validating these pages; update both that enum and its remaining typer prose in the same change.

Useful? React with 👍 / 👎.

tool: write_note # section-3 pages: the MCP tool documented
command: basic-memory status # section-1 pages: the CLI command documented
verified: 0.21.6 mcp+cli # version + path(s) that proved the page
Expand Down Expand Up @@ -186,12 +186,16 @@ GOTCHAS, SEE ALSO, observations) survives — that ownership split is what the

## Roadmap

- **Registry generator (SYNOPSIS: shipped)** — `just man-regen` renders every
section-3 MCP SYNOPSIS block from the live tool registry and a test holds
the shipped blocks byte-equal to the rendering, so a tool change without a
regenerate fails CI. Those pages declare `generated: registry`. Still to
come: PARAMETERS from the schema descriptions, and section-1 from Typer
help — the hand-written corpus remains the template spec.
- **Registry generator (section 3: shipped)** — `just man-regen` renders every
section-3 MCP SYNOPSIS and PARAMETERS block from the live tool registry and a
test holds the shipped blocks byte-equal to the rendering, so a tool change
without a regenerate fails CI. Those pages declare `generated: registry`.
- **CLI generator (section 1: shipped)** — the same `just man-regen` renders
every section-1 shell SYNOPSIS and OPTIONS block from the Typer command tree
(aliases and paired booleans included, and the full option list, shared and
routing flags included), held byte-equal by a drift test. Those pages declare
`generated: cli`. Curated sections stay hand-owned; the hand-written corpus
remains the template spec for everything else.
- **Projects as consumers** — `bm man install --project <name>` copies the
bundled pages into a project as notes, so `SEE ALSO` becomes traversable
relations and the pages join search. (`bm man <topic>`, `bm man list`, the
Expand Down
8 changes: 4 additions & 4 deletions plugins/claude-code/schemas/manpage.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ settings:
section(enum, Unix manual section number): [1, 3, 5, 7, 8]
name: string, page name without section suffix (e.g. write-note)
summary: string, one-line NAME description
generated?(enum, who owns the mechanical sections): [registry, typer, hand]
generated?(enum, who owns the mechanical sections): [registry, cli, hand]
tool?: string, MCP tool this page documents (section 3 pages)
command?: string, CLI command this page documents (section 1 pages)
verified?: string, version and path that verified this page (e.g. 0.21.6 mcp+cli)
Expand All @@ -42,9 +42,9 @@ manual lives in the Basic Memory team workspace `manual` project.
- **Verified examples** — EXAMPLES contain only commands that actually ran;
the `verified` field records the version and path (mcp, cli, or both).
- **generated** — declares regeneration ownership: `registry` (from the MCP
tool registry) and `typer` (from CLI help) pages get mechanical sections
rewritten; curated sections (EXAMPLES, GOTCHAS, SEE ALSO, observations)
are never overwritten.
tool registry) and `cli` (from the Typer CLI command tree) pages get
mechanical sections rewritten; curated sections (EXAMPLES, GOTCHAS, SEE ALSO,
observations) are never overwritten.
- **gotcha / bug observations** — field knowledge accumulates on pages
without being clobbered by regeneration; bugs link their tracking issues.

Expand Down
95 changes: 81 additions & 14 deletions scripts/update_man_pages.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
"""Regenerate the registry-owned sections of the bundled manual.
"""Regenerate the generator-owned sections of the bundled manual.

The MCP SYNOPSIS and PARAMETERS blocks on every section-3 page whose tool this
build registers are mechanical: they must show exactly what the tool schema
advertises. This script renders those blocks from the live registry
(``mcp.list_tools()``) and rewrites them in place, flipping the page's
``generated:`` field to ``registry`` so the ownership split is declared. Curated
sections — DESCRIPTION, EXAMPLES, GOTCHAS, SEE ALSO — are never touched.
Two section families carry mechanical blocks that must track a source of truth:

Run after changing any MCP tool signature:
- **Section 3** (one page per MCP tool): the MCP SYNOPSIS and PARAMETERS blocks
must show exactly what the tool schema advertises. They are rendered from the
live registry (``mcp.list_tools()``) and the page's ``generated:`` field is set
to ``registry``.
- **Section 1** (one page per ``bm`` verb): the shell SYNOPSIS and OPTIONS blocks
must show exactly what the Typer command tree advertises. They are rendered from
the resolved Click command and the page's ``generated:`` field is set to ``cli``.

Curated sections — DESCRIPTION, EXAMPLES, GOTCHAS, SEE ALSO, and every other
hand-owned block — are never touched.

Run after changing any MCP tool signature or ``bm`` verb option:

just man-regen (or: uv run python scripts/update_man_pages.py)

Expand All @@ -21,18 +27,61 @@
from collections.abc import Mapping
from typing import Any

import click
import typer.main

from basic_memory.man import (
bundled_pages,
declare_ownership,
declare_registry_ownership,
remove_parameters,
render_cli_synopsis,
render_options,
render_parameters,
render_synopsis,
replace_cli_synopsis,
replace_mcp_synopsis,
replace_options,
replace_parameters,
)
from basic_memory.mcp.server import mcp
import basic_memory.mcp.tools # noqa: F401 (importing registers the tools)

# Importing the command modules runs their @app.command()/@app.add_typer
# decorators, which is what populates the Typer command tree. The `bm --version`
# fast path in cli/main.py skips these imports, so a script that walks the tree
# must import them explicitly or every subcommand comes back empty.
from basic_memory.cli.app import app
import basic_memory.cli.commands.posix # noqa: F401 (registers cat/grep/ls/find/tail/head/tree)
import basic_memory.cli.commands.man # noqa: F401 (registers `bm man apropos`)

# Section-1 page name -> `bm` command path. Seven pages resolve directly from the
# page name (`grep` -> `bm grep`); apropos(1) documents `bm man apropos`, a verb on
# the `man` subgroup, so it needs an explicit path. The map lives here rather than
# in page frontmatter because man1/*.md is only ever rewritten by this generator.
SECTION1_COMMAND_PATHS: Mapping[str, str] = {"apropos": "man apropos"}


def resolve_cli_command(command_path: str) -> Any:
"""Walk the Typer/Click command tree to the command a page documents.

``command_path`` is space-separated (``man apropos``); each segment resolves
through its parent group's Click context, the shape Click's ``get_command``
requires. Only non-leaf segments are resolved this way, so ``get_command`` is
never called on a leaf command. Typer vendors its own Click, so the resolved
objects are not instances of the top-level ``click`` classes; ``Any`` is the
honest type.
"""
command: Any = typer.main.get_command(app)
ctx = click.Context(command, info_name="bm")
for segment in command_path.split():
resolved = command.get_command(ctx, segment)
if resolved is None:
raise ValueError(f"no such command: bm {command_path}")
command = resolved
ctx = click.Context(command, info_name=segment, parent=ctx)
return command


def regenerate_page(text: str, tool_name: str, schema: Mapping[str, Any]) -> str:
"""Rewrite the registry-owned sections of one section-3 page from its schema.
Expand All @@ -51,23 +100,41 @@ def regenerate_page(text: str, tool_name: str, schema: Mapping[str, Any]) -> str
return declare_registry_ownership(updated)


def regenerate_cli_page(text: str, command_path: str, command: Any) -> str:
"""Rewrite the CLI-owned sections of one section-1 page from its Click command.

Both SYNOPSIS (the shell form) and OPTIONS are mechanical restatements of the
command's parameters, so both are rendered and replaced in place; ownership is
then declared by flipping ``generated:`` to ``cli``.
"""
updated = replace_cli_synopsis(text, render_cli_synopsis(command_path, command))
updated = replace_options(updated, render_options(command))
return declare_ownership(updated, owner="cli")


async def main() -> None:
tools = {tool.name: tool for tool in await mcp.list_tools(run_middleware=False)}
changed: list[str] = []
for page in bundled_pages():
# Pages for tools this build does not register (hosted-only ones like
# cloud_info) stay hand-owned: there is no schema here to render from.
if page.section != 3 or page.tool not in tools:
continue
text = page.read()
updated = regenerate_page(text, page.tool, tools[page.tool].parameters)
if page.section == 3:
# Pages for tools this build does not register (hosted-only ones like
# cloud_info) stay hand-owned: there is no schema here to render from.
if page.tool not in tools:
continue
updated = regenerate_page(text, page.tool, tools[page.tool].parameters)
elif page.section == 1:
command_path = SECTION1_COMMAND_PATHS.get(page.name, page.name)
updated = regenerate_cli_page(text, command_path, resolve_cli_command(command_path))
else:
continue
if updated != text:
page.path.write_text(updated, encoding="utf-8")
changed.append(page.title)
if changed:
print(f"updated {len(changed)} page(s): {', '.join(changed)}")
else:
print("all pages already match the registry")
print("all pages already match their source")


if __name__ == "__main__":
Expand Down
Loading
Loading