scripts/update_man_pages.py
Rama main · réplica de lectura del repositorio original.
"""Regenerate the generator-owned sections of the bundled manual.
Two section families carry mechanical blocks that must track a source of truth:
- **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)
A new MCP tool gets its page scaffolded from the registry, then curated by hand:
uv run python scripts/update_man_pages.py --new bm_read bm_write
A test (tests/test_man_pages.py) holds every shipped block byte-equal to the
rendering, so a forgotten run fails CI with a pointer here.
"""
from __future__ import annotations
import argparse
import asyncio
from collections.abc import Mapping
from typing import Any
import click
import typer.main
import yaml
from basic_memory import __version__
from basic_memory.man import (
MAN_DIR,
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, set_project_delete_visibility
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/head/tree)
import basic_memory.cli.commands.okf # noqa: F401 (registers OKF commands)
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",
"okf-export": "okf export",
"okf-check": "okf check",
}
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.
SYNOPSIS is always mechanical. PARAMETERS exists exactly when the schema has
properties: a tool with parameters gets the rendered block (rewritten in place
or inserted), and a parameterless tool gets none — any previously generated
block is stripped so the page never advertises removed arguments. Ownership is
then declared by flipping ``generated:`` to ``registry``.
"""
updated = replace_mcp_synopsis(text, render_synopsis(tool_name, schema))
if schema.get("properties"):
updated = replace_parameters(updated, render_parameters(tool_name, schema))
else:
updated = remove_parameters(updated)
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")
def scaffold_page(tool_name: str, description: str, schema: Mapping[str, Any]) -> str:
"""A new section-3 page for a registered tool, ready for curation.
The page name is the tool name with hyphens (bm_read -> bm-read). SYNOPSIS and
PARAMETERS come from the schema, as regeneration would write them; NAME takes the
description's first sentence and DESCRIPTION the rest of it. EXAMPLES and SEE
ALSO are left for the author, since only verified calls belong there.
"""
name = tool_name.replace("_", "-")
summary, _, rest = description.strip().partition(". ")
summary = summary.rstrip(".")
lowered = summary[:1].lower() + summary[1:]
sections = [
"---",
f"title: {name}(3)",
"type: manpage",
"section: 3",
f"name: {name}",
# A summary may hold ': ', which plain YAML would read as a mapping.
f"summary: {yaml.safe_dump(lowered, width=1_000).removesuffix(chr(10)).removesuffix('...').strip()}",
"generated: registry",
f"tool: {tool_name}",
f"verified: {__version__} mcp",
"---",
"",
f"# {name}(3)",
"",
"## NAME",
"",
f"**{name}** — {lowered}",
"",
"## SYNOPSIS",
"",
"```",
render_synopsis(tool_name, schema),
"```",
"",
"## DESCRIPTION",
"",
rest.strip() or f"{summary}.",
"",
]
if schema.get("properties"):
sections += ["## PARAMETERS", "", render_parameters(tool_name, schema), ""]
sections += ["## EXAMPLES", "", "```", "```", "", "## SEE ALSO", ""]
return "\n".join(sections)
def scaffold(tools: Mapping[str, Any], tool_names: list[str]) -> None:
"""Write a new page for each named tool; an existing page is never overwritten."""
documented = {page.tool for page in bundled_pages() if page.section == 3}
for tool_name in tool_names:
if tool_name not in tools:
raise SystemExit(f"no registered tool named {tool_name}")
if tool_name in documented:
print(f"{tool_name} already has a page; left as is")
continue
tool = tools[tool_name]
name = tool_name.replace("_", "-")
path = MAN_DIR / "man3" / f"{name}(3).md"
path.write_text(
scaffold_page(tool_name, tool.description or "", tool.parameters), encoding="utf-8"
)
print(f"created {path.relative_to(MAN_DIR)}")
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--new", nargs="+", metavar="TOOL", help="scaffold section-3 pages for these tools"
)
arguments = parser.parse_args()
# The manual documents every tool, including the project-delete tools that a
# server shows only when enable_project_delete is on.
set_project_delete_visibility(mcp, True)
tools = {tool.name: tool for tool in await mcp.list_tools(run_middleware=False)}
if arguments.new:
scaffold(tools, arguments.new)
# bundled_pages() is cached; a scaffold run ends here so the next
# regeneration reads the new pages fresh.
return
changed: list[str] = []
for page in bundled_pages():
text = page.read()
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 their source")
if __name__ == "__main__":
asyncio.run(main())