From e49576a20912a6e56f0f14640c2773732a8571af Mon Sep 17 00:00:00 2001 From: FBISiri Date: Tue, 8 Sep 2026 14:45:28 +0800 Subject: [PATCH 1/2] feat(cli): generate section-1 man SYNOPSIS and OPTIONS from Typer The eight bundled section-1 pages (apropos, cat, find, grep, head, ls, tail, tree) declared `generated: hand`, and their SYNOPSIS and OPTIONS blocks were hand restatements of the POSIX verb definitions in cli/commands/posix.py. They had already drifted: grep(1)'s SYNOPSIS listed --json/--plain/--project/--local/--cloud while its OPTIONS documented only -F and the pagination flags. Render both blocks from the Typer command tree and lock them with a byte-equality drift test, mirroring what #1478 did for section-3 PARAMETERS from the MCP registry: - man/__init__.py: render_cli_synopsis()/render_options() from a resolved Click command; _SYNOPSIS_BODY_RE/_OPTIONS_RE + extract/replace helpers; declare_ownership(text, owner=...) with declare_registry_ownership kept as a thin wrapper. - scripts/update_man_pages.py: a section-1 branch in the existing `just man-regen` pipeline; resolve_cli_command() walks the command tree (apropos maps to `bm man apropos`). - tests/test_man_pages.py: drift test, ownership, shared/global flags in OPTIONS, alias + paired-boolean rendering, block-scoped replacement. Per the maintainer's call (#610), the ownership token is `generated: cli`. OPTIONS renders the complete public option list including the shared output/routing flags (grep(1) grows from four bullets to the full set), and preserves CLI syntax PARAMETERS has no concept of: flag aliases (`-F, --literal`) and paired booleans (`--frontmatter / --no-frontmatter`). Curated sections (NAME, DESCRIPTION, EXAMPLES, SEE ALSO) stay byte-identical and hand-owned; the groff sources are out of scope. Refs #610 Signed-off-by: FBISiri --- docs/manual-pages.md | 18 ++- scripts/update_man_pages.py | 95 +++++++++-- src/basic_memory/man/__init__.py | 203 +++++++++++++++++++++++- src/basic_memory/man/man1/apropos(1).md | 12 +- src/basic_memory/man/man1/cat(1).md | 25 +-- src/basic_memory/man/man1/find(1).md | 37 ++--- src/basic_memory/man/man1/grep(1).md | 25 +-- src/basic_memory/man/man1/head(1).md | 21 +-- src/basic_memory/man/man1/ls(1).md | 15 +- src/basic_memory/man/man1/tail(1).md | 17 +- src/basic_memory/man/man1/tree(1).md | 22 ++- tests/test_man_pages.py | 149 +++++++++++++++++ 12 files changed, 544 insertions(+), 95 deletions(-) diff --git a/docs/manual-pages.md b/docs/manual-pages.md index a76092fc5..8571c26b0 100644 --- a/docs/manual-pages.md +++ b/docs/manual-pages.md @@ -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) 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 @@ -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 ` copies the bundled pages into a project as notes, so `SEE ALSO` becomes traversable relations and the pages join search. (`bm man `, `bm man list`, the diff --git a/scripts/update_man_pages.py b/scripts/update_man_pages.py index a66eb6f88..f99363ad0 100644 --- a/scripts/update_man_pages.py +++ b/scripts/update_man_pages.py @@ -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) @@ -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. @@ -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__": diff --git a/src/basic_memory/man/__init__.py b/src/basic_memory/man/__init__.py index 74b27f82e..8d42e45ee 100644 --- a/src/basic_memory/man/__init__.py +++ b/src/basic_memory/man/__init__.py @@ -165,6 +165,14 @@ def find_page(ref: PageRef) -> ManPage | None: # `## ` heading (or EOF), leaving that separator out of the captured body. _PARAMETERS_RE = re.compile(r"(## PARAMETERS\n\n)(.*?)(?=\n+## |\n*\Z)", re.S) +# Matches the whole body under ## SYNOPSIS and ## OPTIONS on a section-1 page, up +# to the blank line before the next `## ` heading (or EOF). The single-block +# _MCP_SYNOPSIS_RE cannot serve SYNOPSIS here: find(1) documents two shell forms +# in two fenced blocks, and the CLI generator collapses them to one, so the whole +# section body — every fenced block — has to be replaced, not just the first. +_SYNOPSIS_BODY_RE = re.compile(r"(## SYNOPSIS\n\n)(.*?)(?=\n+## |\n*\Z)", re.S) +_OPTIONS_RE = re.compile(r"(## OPTIONS\n\n)(.*?)(?=\n+## |\n*\Z)", re.S) + def _default_literal(value: object) -> str: """Render a schema default the way the call would be written in Python.""" @@ -298,6 +306,133 @@ def render_parameters(tool_name: str, parameters: Mapping[str, Any]) -> str: return "\n".join(bullets) +# --- Typer-generated SYNOPSIS and OPTIONS (section 1) --- +# The SYNOPSIS shell form and the OPTIONS list on a section-1 page are mechanical: +# they must show exactly the command the Typer command tree advertises, the same +# way section 3's SYNOPSIS/PARAMETERS track the MCP registry. These helpers render +# both from a resolved Click command; scripts/update_man_pages.py runs them over the +# section-1 corpus and a test holds every shipped block byte-equal to the rendering. +# +# The renderers take an already-resolved Click command (Typer builds Click objects +# via typer.main.get_command) rather than importing Typer or Click here, so the +# lightweight `basic_memory.man` import stays free of the CLI stack. Command +# parameters are read structurally through Click's public attributes +# (``param_type_name``, ``opts``, ``secondary_opts``, ``is_flag``, ``required``, +# ``default``, ``help``), so ``Any`` is the honest type for the passed command. + + +def _order_opts(opts: list[str]) -> list[str]: + """Order an option's spellings short flags first, then long ones. + + ``-p, --project`` reads the way people write it; a stable sort keeps the + declared order within each group so multi-short or multi-long spellings hold + their author-chosen sequence. + """ + return sorted(opts, key=lambda opt: opt.startswith("--")) + + +def _synopsis_opt(opts: list[str]) -> str: + """The spelling to show for an option in the shell SYNOPSIS: its long form. + + The long form names the option unambiguously; a short-only option falls back + to its first (short) spelling. + """ + longs = [opt for opt in opts if opt.startswith("--")] + return longs[0] if longs else opts[0] + + +def render_cli_synopsis(command_path: str, command: Any) -> str: + """Render a section-1 page's shell SYNOPSIS from a resolved Click command. + + Positional arguments come first in declaration order (bare when required, + bracketed when optional), then every public option as a bracketed token: + ``[--flag]`` for a boolean flag, ``[--on | --no-on]`` for a boolean pair, and + ``[--opt METAVAR]`` for a value option (metavar is the parameter name upper- + cased). Lines wrap at the code block's width with continuations aligned under + the command name — mirroring render_synopsis's wrap for the MCP form. + """ + tokens: list[str] = [] + for param in command.params: + if param.param_type_name != "argument": + continue + metavar = param.name.upper() + tokens.append(metavar if param.required else f"[{metavar}]") + for param in command.params: + if param.param_type_name != "option" or getattr(param, "hidden", False): + continue + opt = _synopsis_opt(param.opts) + if param.secondary_opts: + tokens.append(f"[{opt} | {_synopsis_opt(param.secondary_opts)}]") + elif param.is_flag: + tokens.append(f"[{opt}]") + else: + tokens.append(f"[{opt} {param.name.upper()}]") + + prefix = f"bm {command_path}" + indent = " " * (len(prefix) + 1) + lines: list[str] = [] + line = prefix + count = 0 # tokens already placed on the current line + for token in tokens: + candidate = f"{line} {token}" + # Wrap only once a line carries a token, so a token longer than the width + # still lands (overlong but unbroken) rather than looping. + if count > 0 and len(candidate) > SYNOPSIS_WIDTH: + lines.append(line) + line = indent + token + count = 1 + else: + line = candidate + count += 1 + lines.append(line) + return "\n".join(lines) + + +def _option_default_note(param: Any) -> str | None: + """The ``default: ...`` note for an option bullet, or None when there is none. + + A boolean pair reports which flag is on by default (``default: --frontmatter``); + a bare flag reports nothing, since off is simply its absence; a value option + reports its default literal when the schema carries one. + """ + default = param.default + if param.secondary_opts: + flags = param.opts if default else param.secondary_opts + longs = [opt for opt in flags if opt.startswith("--")] + return f"default: {longs[0] if longs else flags[0]}" + if param.is_flag or default is None: + return None + return f"default: {_default_literal(default)}" + + +def render_options(command: Any) -> str: + """Render a section-1 page's ## OPTIONS body from a resolved Click command. + + Every public option is one bullet, in declaration order (the order --help + lists them). Reusing render_parameters's conventions — default literals, + single-line descriptions, bullet shape — the head carries the CLI-specific + syntax those have no concept of: flag aliases join short-first as + ``-F, --literal`` and a boolean pair shows both sides as + ``--frontmatter / --no-frontmatter``. Positional arguments are not options; + they appear in the SYNOPSIS instead. Returns "" when the command has no + options. + """ + bullets: list[str] = [] + for param in command.params: + if param.param_type_name != "option" or getattr(param, "hidden", False): + continue + opts_display = ", ".join(_order_opts(param.opts)) + if param.secondary_opts: + opts_display += f" / {', '.join(_order_opts(param.secondary_opts))}" + head = f"- **{opts_display}**" + default_note = _option_default_note(param) + if default_note is not None: + head += f" ({default_note})" + description = _normalise_description(param.help) + bullets.append(f"{head} — {description}" if description else head) + return "\n".join(bullets) + + def extract_mcp_synopsis(page_text: str) -> str: """The MCP call block a page currently shows under ## SYNOPSIS.""" match = _MCP_SYNOPSIS_RE.search(page_text) @@ -374,15 +509,75 @@ def remove_parameters(page_text: str) -> str: return f"{before}\n\n{after}" if after else f"{before}\n" +def extract_cli_synopsis(page_text: str) -> str: + """The shell form a section-1 page currently shows under ## SYNOPSIS. + + Returns the fenced block's inner text (the ``bm ...`` lines). Raises if the + section is missing or is not a single fenced block — the shape the CLI + generator writes and the drift test compares against. + """ + match = _SYNOPSIS_BODY_RE.search(page_text) + if match is None: + raise ValueError("page has no SYNOPSIS block") + body = match.group(2) + inner = body.removeprefix("```\n").removesuffix("\n```") + if not (body.startswith("```\n") and body.endswith("\n```")) or "```" in inner: + raise ValueError("SYNOPSIS is not a single fenced block") + return inner + + +def replace_cli_synopsis(page_text: str, synopsis: str) -> str: + """Return the page with its whole ## SYNOPSIS body replaced by one fenced block. + + The entire body is replaced, not just the first fence, so a page that shipped + several shell forms (find(1)) collapses to the one the generator renders. Other + sections are untouched. + """ + match = _SYNOPSIS_BODY_RE.search(page_text) + if match is None: + raise ValueError("page has no SYNOPSIS block") + fenced = f"```\n{synopsis}\n```" + return f"{page_text[: match.start()]}{match.group(1)}{fenced}{page_text[match.end() :]}" + + +def extract_options(page_text: str) -> str: + """The bullet body a section-1 page currently shows under ## OPTIONS.""" + match = _OPTIONS_RE.search(page_text) + if match is None: + raise ValueError("page has no OPTIONS block") + return match.group(2) + + +def replace_options(page_text: str, options: str) -> str: + """Return the page with its ## OPTIONS body replaced in place; other blocks + untouched. + + Every bundled section-1 page already carries an OPTIONS heading, so this + rewrites the block rather than inserting one, and fails fast if a page lacks it. + """ + match = _OPTIONS_RE.search(page_text) + if match is None: + raise ValueError("page has no OPTIONS block") + return f"{page_text[: match.start()]}{match.group(1)}{options}{page_text[match.end() :]}" + + def declare_registry_ownership(page_text: str) -> str: - """Flip ``generated: hand`` to ``registry`` — in the frontmatter only. + """Declare a section-3 page registry-owned — a thin wrapper over declare_ownership.""" + return declare_ownership(page_text, owner="registry") + + +def declare_ownership(page_text: str, owner: str = "cli") -> str: + """Rewrite the frontmatter's ``generated:`` field to ``owner`` — nothing else. - A curated body may legally contain a literal ``generated: hand`` line (a YAML - example, say); only the opening frontmatter block is the generator's to rewrite. + ``generated:`` declares who may rewrite a page's mechanical sections: the MCP + registry generator (``registry``) or the Typer CLI generator (``cli``). A + curated body may legally contain a literal ``generated: ...`` line (a YAML + example, say); only the opening frontmatter block is the generator's to rewrite, + and only the first such line in it, so the count stays 1. """ frontmatter, fence, body = page_text.partition("\n---\n") frontmatter = re.sub( - r"^generated: hand$", "generated: registry", frontmatter, count=1, flags=re.M + r"^generated: \w+$", f"generated: {owner}", frontmatter, count=1, flags=re.M ) return frontmatter + fence + body diff --git a/src/basic_memory/man/man1/apropos(1).md b/src/basic_memory/man/man1/apropos(1).md index 2b73fda5a..441805116 100644 --- a/src/basic_memory/man/man1/apropos(1).md +++ b/src/basic_memory/man/man1/apropos(1).md @@ -4,7 +4,7 @@ type: manpage section: 1 name: apropos summary: search the Basic Memory manual -generated: hand +generated: cli --- # apropos(1) @@ -16,8 +16,8 @@ generated: hand ## SYNOPSIS ``` -bm man apropos QUERY [--project NAME] [--json | --plain] - [--local | --cloud] +bm man apropos QUERY [--project PROJECT] [--json] [--plain] [--local] + [--cloud] ``` ## DESCRIPTION @@ -32,7 +32,11 @@ An empty result is a successful search (exit 0), not an error. ## OPTIONS -- **--project** — manual project to search (default: `manual`) +- **--project** — Manual project to search (default: manual) +- **--json** — Output raw JSON instead of formatted display +- **--plain** — Output undecorated plain text (no colors/markup), even when piped +- **--local** — Force local API routing (ignore cloud mode) +- **--cloud** — Force cloud API routing ## EXAMPLES diff --git a/src/basic_memory/man/man1/cat(1).md b/src/basic_memory/man/man1/cat(1).md index 0ae0120ee..c6f6e49bd 100644 --- a/src/basic_memory/man/man1/cat(1).md +++ b/src/basic_memory/man/man1/cat(1).md @@ -4,7 +4,7 @@ type: manpage section: 1 name: cat summary: print a note's content from the shell -generated: hand +generated: cli --- # cat(1) @@ -16,9 +16,10 @@ generated: hand ## SYNOPSIS ``` -bm cat IDENTIFIER [--lines N-M | --section HEADING] [--max-tokens N] - [--frontmatter | --no-frontmatter] [--json | --plain] - [--project NAME | --project-id UUID] [--local | --cloud] +bm cat IDENTIFIER [--lines LINES] [--section SECTION] + [--max-tokens MAX_TOKENS] [--frontmatter | --no-frontmatter] [--json] + [--plain] [--project PROJECT] [--project-id PROJECT_ID] [--local] + [--cloud] ``` ## DESCRIPTION @@ -36,12 +37,16 @@ total_lines, truncated, continue_line). ## OPTIONS -- **--lines** — line range; cannot combine with --section -- **--section** — heading slice; the response's line range supports - follow-up `--lines` reads -- **--max-tokens** — truncate at a section/paragraph boundary -- **--frontmatter/--no-frontmatter** — include the YAML block (ignored for - section/token slices) +- **--lines** — Line range "N-M", "N-" (to end), or "N" (one line); 1-indexed inclusive +- **--section** — Heading slice: "Decisions", "Auth/Decisions", or "Heading[1]" +- **--max-tokens** — Approximate token budget; truncates at a section/paragraph boundary +- **--frontmatter / --no-frontmatter** (default: --frontmatter) — Include the YAML frontmatter block (ignored for section/token slices) +- **--json** — Output raw JSON instead of formatted display +- **--plain** — Output undecorated plain text (no colors/markup), even when piped +- **--project** — The project to use. If not provided, the default project will be used. +- **--project-id** — Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces. +- **--local** — Force local API routing (ignore cloud mode) +- **--cloud** — Force cloud API routing ## EXAMPLES diff --git a/src/basic_memory/man/man1/find(1).md b/src/basic_memory/man/man1/find(1).md index 11d7b61f0..7c87630be 100644 --- a/src/basic_memory/man/man1/find(1).md +++ b/src/basic_memory/man/man1/find(1).md @@ -4,7 +4,7 @@ type: manpage section: 1 name: find summary: recursively list files, or query notes by frontmatter metadata -generated: hand +generated: cli --- # find(1) @@ -16,13 +16,10 @@ generated: hand ## SYNOPSIS ``` -bm find [PATH] [--name GLOB] [--depth N] [--page N] [--page-size N] - [--json | --plain] [--project NAME | --project-id UUID] - [--local | --cloud] - -bm find [PATH] --meta PREDICATE [--meta PREDICATE ...] [--fields LIST] - [--page N] [--page-size N] [--json | --plain] - [--project NAME | --project-id UUID] [--local | --cloud] +bm find [PATH] [--name NAME] [--depth DEPTH] [--page PAGE] + [--page-size PAGE_SIZE] [--meta META] [--fields FIELDS] [--json] + [--plain] [--project PROJECT] [--project-id PROJECT_ID] [--local] + [--cloud] ``` ## DESCRIPTION @@ -131,18 +128,18 @@ predicates keep their existing frontmatter comparison behavior. ## OPTIONS -- **--name** — file-name glob, e.g. `"*.md"`; omitted matches everything. - Cannot combine with `--meta` -- **--depth** — recursion depth, 1-10 (default 10). A non-default depth - cannot combine with `--meta` -- **--meta** — frontmatter predicate, repeatable; see PREDICATE GRAMMAR. - Switches the payload to the search response shape -- **--fields** — comma-separated frontmatter fields to show per hit, e.g. - `"title,priority"`; dot-paths allowed, in the same shape predicate keys - take, and a malformed one is refused rather than shown as null for every - hit. A field a note does not carry shows as null. Projects each hit down to - its identity plus those fields — no note content. Requires `--meta` -- **--page, --page-size** — pagination (defaults 1 and 10) +- **--name** — File-name glob, e.g. "*.md" +- **--depth** (default: 10) — Recursion depth (API bound 1-10) +- **--page** (default: 1) — Page number (1-indexed) +- **--page-size** (default: 10) — Nodes per page +- **--meta** — Metadata predicate, repeatable: 'status=active', 'confidence>0.6', 'priority in high,critical', 'tags has security', 'score between 0.3,0.8', 'owner=null' (key missing or null). PATH still scopes the query, by file path +- **--fields** — Comma-separated frontmatter fields to show per hit, e.g. "title,priority" (requires --meta) +- **--json** — Output raw JSON instead of formatted display +- **--plain** — Output undecorated plain text (no colors/markup), even when piped +- **--project** — The project to use. If not provided, the default project will be used. +- **--project-id** — Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces. +- **--local** — Force local API routing (ignore cloud mode) +- **--cloud** — Force cloud API routing ## EXAMPLES diff --git a/src/basic_memory/man/man1/grep(1).md b/src/basic_memory/man/man1/grep(1).md index 79687df22..3784c6a6a 100644 --- a/src/basic_memory/man/man1/grep(1).md +++ b/src/basic_memory/man/man1/grep(1).md @@ -4,7 +4,7 @@ type: manpage section: 1 name: grep summary: search note content from the shell -generated: hand +generated: cli --- # grep(1) @@ -16,10 +16,10 @@ generated: hand ## SYNOPSIS ``` -bm grep PATTERN [-F | --literal] [--page N] [--page-size N] - [-C N | --context-lines N] [--max-matches N] - [--json | --plain] [--project NAME | --project-id UUID] - [--local | --cloud] +bm grep PATTERN [--literal] [--context-lines CONTEXT_LINES] + [--max-matches MAX_MATCHES] [--page PAGE] [--page-size PAGE_SIZE] + [--json] [--plain] [--project PROJECT] [--project-id PROJECT_ID] + [--local] [--cloud] ``` ## DESCRIPTION @@ -46,10 +46,17 @@ Line positions can change if the note is edited between calls. ## OPTIONS -- **-F, --literal** — literal full-text matching instead of semantic search -- **-C, --context-lines** — opt into line scanning with 0-10 lines around each match; requires -F -- **--max-matches** — matching lines to show per candidate in line mode, 1-100 (default 10) -- **--page, --page-size** — result pagination (defaults 1 and 10) +- **-F, --literal** — Literal full-text matching instead of semantic search +- **-C, --context-lines** — Compact literal line matches with surrounding context (requires -F) +- **--max-matches** (default: 10) — Matching lines per candidate in context mode +- **--page** (default: 1) — Page number (1-indexed) +- **--page-size** (default: 10) — Results per page +- **--json** — Output raw JSON instead of formatted display +- **--plain** — Output undecorated plain text (no colors/markup), even when piped +- **--project** — The project to use. If not provided, the default project will be used. +- **--project-id** — Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces. +- **--local** — Force local API routing (ignore cloud mode) +- **--cloud** — Force cloud API routing ## EXAMPLES diff --git a/src/basic_memory/man/man1/head(1).md b/src/basic_memory/man/man1/head(1).md index 9dfcd2574..86007e7d4 100644 --- a/src/basic_memory/man/man1/head(1).md +++ b/src/basic_memory/man/man1/head(1).md @@ -4,7 +4,7 @@ type: manpage section: 1 name: head summary: print the first lines of a note -generated: hand +generated: cli --- # head(1) @@ -16,9 +16,9 @@ generated: hand ## SYNOPSIS ``` -bm head IDENTIFIER [-n N] [--frontmatter | --no-frontmatter] - [--json | --plain] [--project NAME | --project-id UUID] - [--local | --cloud] +bm head IDENTIFIER [--lines N] [--frontmatter | --no-frontmatter] [--json] + [--plain] [--project PROJECT] [--project-id PROJECT_ID] [--local] + [--cloud] ``` ## DESCRIPTION @@ -30,11 +30,14 @@ end_line, and total_lines. ## OPTIONS -- **-n, --lines** — number of lines to print, from line 1 (default 10) -- **--frontmatter/--no-frontmatter** — include the YAML block; with - --frontmatter (the default) line numbers address the full document, - frontmatter included, and with --no-frontmatter they address the - frontmatter-stripped body +- **-n, --lines** (default: 10) — Number of lines to print (from line 1) +- **--frontmatter / --no-frontmatter** (default: --frontmatter) — Include the YAML frontmatter block +- **--json** — Output raw JSON instead of formatted display +- **--plain** — Output undecorated plain text (no colors/markup), even when piped +- **--project** — The project to use. If not provided, the default project will be used. +- **--project-id** — Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces. +- **--local** — Force local API routing (ignore cloud mode) +- **--cloud** — Force cloud API routing ## EXAMPLES diff --git a/src/basic_memory/man/man1/ls(1).md b/src/basic_memory/man/man1/ls(1).md index 6cacd229c..fab405926 100644 --- a/src/basic_memory/man/man1/ls(1).md +++ b/src/basic_memory/man/man1/ls(1).md @@ -4,7 +4,7 @@ type: manpage section: 1 name: ls summary: list one directory level of a project -generated: hand +generated: cli --- # ls(1) @@ -16,8 +16,8 @@ generated: hand ## SYNOPSIS ``` -bm ls [PATH] [--page N] [--page-size N] [--json | --plain] - [--project NAME | --project-id UUID] [--local | --cloud] +bm ls [PATH] [--page PAGE] [--page-size PAGE_SIZE] [--json] [--plain] + [--project PROJECT] [--project-id PROJECT_ID] [--local] [--cloud] ``` ## DESCRIPTION @@ -33,7 +33,14 @@ projects. ## OPTIONS -- **--page, --page-size** — node pagination (defaults 1 and 10) +- **--page** (default: 1) — Page number (1-indexed) +- **--page-size** (default: 10) — Nodes per page +- **--json** — Output raw JSON instead of formatted display +- **--plain** — Output undecorated plain text (no colors/markup), even when piped +- **--project** — The project to use. If not provided, the default project will be used. +- **--project-id** — Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces. +- **--local** — Force local API routing (ignore cloud mode) +- **--cloud** — Force cloud API routing ## EXAMPLES diff --git a/src/basic_memory/man/man1/tail(1).md b/src/basic_memory/man/man1/tail(1).md index 4fb5d069f..869fb3e73 100644 --- a/src/basic_memory/man/man1/tail(1).md +++ b/src/basic_memory/man/man1/tail(1).md @@ -4,7 +4,7 @@ type: manpage section: 1 name: tail summary: show recently changed notes -generated: hand +generated: cli --- # tail(1) @@ -16,8 +16,8 @@ generated: hand ## SYNOPSIS ``` -bm tail [--timeframe WINDOW] [-n N] [--json | --plain] - [--project NAME | --project-id UUID] [--local | --cloud] +bm tail [--timeframe TIMEFRAME] [--lines N] [--json] [--plain] + [--project PROJECT] [--project-id PROJECT_ID] [--local] [--cloud] ``` ## DESCRIPTION @@ -30,9 +30,14 @@ output) emits the rows as a JSON array. ## OPTIONS -- **--timeframe** — time window, e.g. `7d`, `yesterday`, `2 days ago` - (default `7d`) -- **-n, --lines** — rows to show, 1-100 (default 10) +- **--timeframe** (default: "7d") — Time window, e.g. "7d", "yesterday" +- **-n, --lines** (default: 10) — Rows to show (1-100) +- **--json** — Output raw JSON instead of formatted display +- **--plain** — Output undecorated plain text (no colors/markup), even when piped +- **--project** — The project to use. If not provided, the default project will be used. +- **--project-id** — Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces. +- **--local** — Force local API routing (ignore cloud mode) +- **--cloud** — Force cloud API routing ## EXAMPLES diff --git a/src/basic_memory/man/man1/tree(1).md b/src/basic_memory/man/man1/tree(1).md index b1c891747..694a47faf 100644 --- a/src/basic_memory/man/man1/tree(1).md +++ b/src/basic_memory/man/man1/tree(1).md @@ -4,7 +4,7 @@ type: manpage section: 1 name: tree summary: show a directory hierarchy -generated: hand +generated: cli --- # tree(1) @@ -16,9 +16,9 @@ generated: hand ## SYNOPSIS ``` -bm tree [PATH] [--name GLOB] [--depth N] [--page N] [--page-size N] - [--json | --plain] [--project NAME | --project-id UUID] - [--local | --cloud] +bm tree [PATH] [--name NAME] [--depth DEPTH] [--page PAGE] + [--page-size PAGE_SIZE] [--json] [--plain] [--project PROJECT] + [--project-id PROJECT_ID] [--local] [--cloud] ``` ## DESCRIPTION @@ -31,10 +31,16 @@ listing payload — the hierarchy is a display concern. ## OPTIONS -- **--name** — file-name glob, e.g. `"*.md"` -- **--depth** — recursion depth, 1-10 (default 10) -- **--page, --page-size** — node pagination; a truncated page notes that - more entries exist +- **--name** — File-name glob, e.g. "*.md" +- **--depth** (default: 10) — Recursion depth (API bound 1-10) +- **--page** (default: 1) — Page number (1-indexed) +- **--page-size** (default: 10) — Nodes per page +- **--json** — Output raw JSON instead of formatted display +- **--plain** — Output undecorated plain text (no colors/markup), even when piped +- **--project** — The project to use. If not provided, the default project will be used. +- **--project-id** — Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces. +- **--local** — Force local API routing (ignore cloud mode) +- **--cloud** — Force cloud API routing ## EXAMPLES diff --git a/tests/test_man_pages.py b/tests/test_man_pages.py index d469076b2..c20d1305e 100644 --- a/tests/test_man_pages.py +++ b/tests/test_man_pages.py @@ -12,16 +12,23 @@ MAN_DIR, PageRef, bundled_pages, + declare_ownership, declare_registry_ownership, + extract_cli_synopsis, extract_mcp_synopsis, + extract_options, extract_parameters, find_page, parse_page_ref, remove_parameters, + render_cli_synopsis, render_index, + render_options, render_parameters, render_synopsis, + replace_cli_synopsis, replace_mcp_synopsis, + replace_options, replace_parameters, ) from basic_memory.mcp.server import mcp @@ -37,6 +44,7 @@ update_man_pages = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(update_man_pages) regenerate_page = update_man_pages.regenerate_page +regenerate_cli_page = update_man_pages.regenerate_cli_page def _has_parameters_block(page_text: str) -> bool: @@ -409,3 +417,144 @@ def test_render_index_lists_every_page_with_uri_and_summary() -> None: assert "## Section 3 — MCP tools" in index for page in bundled_pages(): assert f"- [{page.title}]({page.uri}) — {page.summary}" in index + + +# --- Section 1: CLI SYNOPSIS and OPTIONS from the Typer command tree --- +# The shell SYNOPSIS and OPTIONS blocks on a section-1 page are mechanical +# restatements of a `bm` verb's parameters, the CLI counterpart of section 3's +# registry-owned SYNOPSIS/PARAMETERS. These tests hold them byte-equal to the live +# Typer rendering and pin the CLI-specific syntax the section-3 renderers lack. + + +def _cli_command(page): + """Resolve the Click command a section-1 page documents, with its `bm` path.""" + command_path = update_man_pages.SECTION1_COMMAND_PATHS.get(page.name, page.name) + return command_path, update_man_pages.resolve_cli_command(command_path) + + +def test_render_cli_synopsis_renders_the_shell_form() -> None: + _, grep = _cli_command(find_page(PageRef("grep", 1))) + synopsis = render_cli_synopsis("grep", grep) + + assert synopsis.startswith("bm grep PATTERN") + assert "[--literal]" in synopsis # a boolean flag renders bare + assert "[--page PAGE]" in synopsis # a value option carries a metavar + assert all(len(line) <= 76 for line in synopsis.splitlines()) + # Continuations align under the command name, like render_synopsis's wrap. + for line in synopsis.splitlines()[1:]: + assert line.startswith(" " * len("bm grep ")) + + # apropos(1) documents `bm man apropos`, a verb on the man subgroup, so its + # shell form carries the full command path rather than a bare `bm apropos`. + apropos_path, apropos = _cli_command(find_page(PageRef("apropos", 1))) + assert apropos_path == "man apropos" + assert render_cli_synopsis(apropos_path, apropos).startswith("bm man apropos QUERY") + + +def test_render_options_includes_shared_and_global_flags() -> None: + # D2: OPTIONS is the COMPLETE public option list, including the shared output + # and routing flags the hand-written blocks left out — grep(1) grows from four + # bullets to the full set. That growth is the point, not a regression. + _, grep = _cli_command(find_page(PageRef("grep", 1))) + options = render_options(grep) + for shared in ("--json", "--plain", "--project", "--project-id", "--local", "--cloud"): + assert f"- **{shared}**" in options, f"{shared} missing from generated OPTIONS" + + +def test_render_options_preserves_aliases_and_boolean_pairs() -> None: + # D3: OPTIONS keeps the CLI syntax section-3 PARAMETERS has no concept of — + # flag aliases (-F, --literal) and paired booleans (--x / --no-x). + _, grep = _cli_command(find_page(PageRef("grep", 1))) + assert "- **-F, --literal** — Literal full-text matching" in render_options(grep) + + _, cat = _cli_command(find_page(PageRef("cat", 1))) + assert "- **--frontmatter / --no-frontmatter** (default: --frontmatter)" in render_options(cat) + + +def test_replace_options_touches_only_the_options_block() -> None: + page = ( + "# t\n\n## SYNOPSIS\n\n```\nbm t\n```\n\n" + "## OPTIONS\n\n- **--old** — old\n\n" + "## EXAMPLES\n\nx\n" + ) + replaced = replace_options(page, "- **--new** — new") + assert extract_options(replaced) == "- **--new** — new" + assert "```\nbm t\n```" in replaced # SYNOPSIS untouched + assert "## EXAMPLES\n\nx\n" in replaced # EXAMPLES untouched + with pytest.raises(ValueError, match="no OPTIONS block"): + replace_options("# t\n\n## DESCRIPTION\n", "- **--x** — x") + with pytest.raises(ValueError, match="no OPTIONS block"): + extract_options("# t\n\n## DESCRIPTION\n") + + +def test_replace_cli_synopsis_replaces_the_whole_synopsis_body() -> None: + # find(1) ships two shell forms; the CLI generator collapses them to one, so + # the whole SYNOPSIS body is replaced, not just the first fenced block. + two_forms = ( + "# t\n\n## SYNOPSIS\n\n```\nbm t --a\n```\n\n```\nbm t --b\n```\n\n" + "## DESCRIPTION\n\nprose\n" + ) + replaced = replace_cli_synopsis(two_forms, "bm t --a --b") + assert extract_cli_synopsis(replaced) == "bm t --a --b" + assert replaced.count("```") == 2 # exactly one fenced block remains + assert "## DESCRIPTION\n\nprose\n" in replaced # DESCRIPTION untouched + with pytest.raises(ValueError, match="no SYNOPSIS block"): + replace_cli_synopsis("# t\n\n## DESCRIPTION\n", "bm t") + with pytest.raises(ValueError, match="no SYNOPSIS block"): + extract_cli_synopsis("# t\n\n## DESCRIPTION\n") + # A SYNOPSIS body that is not one fenced block (unfenced prose, or two blocks) + # is a stale page the generator has not rewritten yet, not a shell form. + with pytest.raises(ValueError, match="not a single fenced block"): + extract_cli_synopsis("# t\n\n## SYNOPSIS\n\nbm t\n\n## DESCRIPTION\n") + with pytest.raises(ValueError, match="not a single fenced block"): + extract_cli_synopsis(two_forms) + + +def test_section_1_synopsis_and_options_are_exactly_the_typer_rendering() -> None: + # SYNOPSIS and OPTIONS are CLI-owned: byte-equal to the rendering of the Typer + # command tree. A verb option change without regenerating the pages fails here, + # pointing at the fix. + for page in bundled_pages(): + if page.section != 1: + continue + command_path, command = _cli_command(page) + page_text = page.read() + assert extract_cli_synopsis(page_text) == render_cli_synopsis(command_path, command), ( + f"{page.title} SYNOPSIS is stale; run `just man-regen` and commit the result" + ) + assert extract_options(page_text) == render_options(command), ( + f"{page.title} OPTIONS is stale; run `just man-regen` and commit the result" + ) + + +def test_section_1_pages_declare_cli_ownership() -> None: + # generated: cli declares the Typer generator owns the mechanical sections, + # the section-1 counterpart of section 3's generated: registry. + for page in bundled_pages(): + if page.section == 1: + assert page.generated == "cli", f"{page.title} declares generated: {page.generated}" + + +def test_regenerate_cli_page_is_idempotent_over_the_shipped_pages() -> None: + # Round-tripping a shipped page through the generator is a no-op: it is what + # produced the page. This mirrors `just man-regen` making no diff. + for page in bundled_pages(): + if page.section != 1: + continue + command_path, command = _cli_command(page) + text = page.read() + assert regenerate_cli_page(text, command_path, command) == text + + +def test_declare_ownership_sets_the_named_owner_in_frontmatter_only() -> None: + # A curated body may contain a literal `generated: ...` line (a YAML example); + # only the opening frontmatter block is the generator's to rewrite. + page = "---\ntitle: t(1)\ngenerated: hand\n---\n\n# t(1)\n\n```yaml\ngenerated: hand\n```\n" + + flipped = declare_ownership(page, owner="cli") + + assert flipped.startswith("---\ntitle: t(1)\ngenerated: cli\n---\n") + assert "```yaml\ngenerated: hand\n```" in flipped + assert declare_ownership(flipped, owner="cli") == flipped + # The thin registry wrapper is declare_ownership with a fixed owner. + assert declare_registry_ownership(page) == declare_ownership(page, owner="registry") From d793482d1821da88eb7c39050ad41a0ceddcd9c1 Mon Sep 17 00:00:00 2001 From: FBISiri Date: Tue, 8 Sep 2026 15:37:06 +0800 Subject: [PATCH 2/2] fix(cli): group mutex options and type Click params in man generation Address the chatgpt-codex review on #1524: - Replace the speculative `getattr(param, "hidden", False)` fallback in render_cli_synopsis/render_options with a structural ClickParam/ClickCommand Protocol and read `param.hidden` directly, so an unexpected parameter shape fails fast instead of being silently treated as public (AGENTS.md: no speculative getattr). The Protocol stays structural, so no Click import is pulled into the lightweight `basic_memory.man` module. - Render mutually exclusive option pairs (--json/--plain, --local/--cloud) as a single `[--a | --b]` alternative in the shell SYNOPSIS, matching the CLI's own rejection of both flags together, instead of flattening them into freely combinable tokens. - Keep Click `multiple=True` options repeatable: `find --meta` renders `[--meta META ...]` again rather than degrading to `[--meta META]`. - Add `cli` to the shipped Manpage schema ownership enum and fix its residual `typer` prose, so a user copying the opt-in schema validates the regenerated section-1 pages without an enum-mismatch warning. Regenerated all 8 section-1 pages via scripts/update_man_pages.py; regeneration is idempotent. Added regression tests for the grouped mutex pairs, the repeatable `...` notation, and hidden-option exclusion. Refs #610 Signed-off-by: FBISiri --- plugins/claude-code/schemas/manpage.md | 8 +- src/basic_memory/man/__init__.py | 126 +++++++++++++++++++----- src/basic_memory/man/man1/apropos(1).md | 4 +- src/basic_memory/man/man1/cat(1).md | 6 +- src/basic_memory/man/man1/find(1).md | 6 +- src/basic_memory/man/man1/grep(1).md | 4 +- src/basic_memory/man/man1/head(1).md | 6 +- src/basic_memory/man/man1/ls(1).md | 4 +- src/basic_memory/man/man1/tail(1).md | 4 +- src/basic_memory/man/man1/tree(1).md | 4 +- tests/test_man_pages.py | 81 +++++++++++++++ 11 files changed, 206 insertions(+), 47 deletions(-) diff --git a/plugins/claude-code/schemas/manpage.md b/plugins/claude-code/schemas/manpage.md index 103758c36..7a3701811 100644 --- a/plugins/claude-code/schemas/manpage.md +++ b/plugins/claude-code/schemas/manpage.md @@ -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) @@ -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. diff --git a/src/basic_memory/man/__init__.py b/src/basic_memory/man/__init__.py index 8d42e45ee..cf82c2101 100644 --- a/src/basic_memory/man/__init__.py +++ b/src/basic_memory/man/__init__.py @@ -14,11 +14,11 @@ import json import re -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass from functools import cache from pathlib import Path -from typing import Any +from typing import Any, Protocol from urllib.parse import unquote from basic_memory.file_utils import parse_frontmatter, remove_frontmatter @@ -315,10 +315,53 @@ def render_parameters(tool_name: str, parameters: Mapping[str, Any]) -> str: # # The renderers take an already-resolved Click command (Typer builds Click objects # via typer.main.get_command) rather than importing Typer or Click here, so the -# lightweight `basic_memory.man` import stays free of the CLI stack. Command -# parameters are read structurally through Click's public attributes -# (``param_type_name``, ``opts``, ``secondary_opts``, ``is_flag``, ``required``, -# ``default``, ``help``), so ``Any`` is the honest type for the passed command. +# lightweight `basic_memory.man` import stays free of the CLI stack. The Click +# parameter surface they read is pinned by a structural Protocol instead of ``Any``, +# so an attribute the renderers depend on (``hidden``, say) is accessed directly and +# a param shape missing it fails fast rather than being silently treated as public +# (AGENTS.md: no speculative getattr). The Protocol stays structural, so no Click +# import is pulled in. + + +class ClickParam(Protocol): + """The Click parameter attributes these section-1 renderers read. + + A resolved command's ``params`` mix arguments and options; every attribute below + is public Click API. The option-only ones (``is_flag``, ``hidden``, ``help``) are + read solely after ``param_type_name == "option"`` has filtered arguments out, so + the renderers never touch them on an argument even though the Protocol names them. + """ + + param_type_name: str + name: str + opts: list[str] + secondary_opts: list[str] + required: bool + is_flag: bool + multiple: bool + hidden: bool + default: Any + help: str | None + + +class ClickCommand(Protocol): + """The resolved Click command surface a section-1 page is rendered from: its params.""" + + @property + def params(self) -> Sequence[ClickParam]: ... + + +# Option pairs the CLI rejects in combination — ``--json``/``--plain`` guarded by +# _validate_output_flags and ``--local``/``--cloud`` by validate_routing_flags in +# cli/commands/posix.py. Click carries no cross-parameter constraint, so the +# generator must name them here: the SYNOPSIS shows a fully-present pair as one +# ``[--json | --plain]`` alternative rather than two freely-combinable tokens, the +# way the curated pages did. Tuples fix the render order (json before plain). Pairs +# whose members are not both present on a command are left ungrouped. +MUTUALLY_EXCLUSIVE_OPTIONS: tuple[tuple[str, ...], ...] = ( + ("--json", "--plain"), + ("--local", "--cloud"), +) def _order_opts(opts: list[str]) -> list[str]: @@ -341,15 +384,35 @@ def _synopsis_opt(opts: list[str]) -> str: return longs[0] if longs else opts[0] -def render_cli_synopsis(command_path: str, command: Any) -> str: - """Render a section-1 page's shell SYNOPSIS from a resolved Click command. +def _synopsis_option_token(param: ClickParam) -> str: + """The bracketed SYNOPSIS token for one public option. - Positional arguments come first in declaration order (bare when required, - bracketed when optional), then every public option as a bracketed token: ``[--flag]`` for a boolean flag, ``[--on | --no-on]`` for a boolean pair, and ``[--opt METAVAR]`` for a value option (metavar is the parameter name upper- - cased). Lines wrap at the code block's width with continuations aligned under - the command name — mirroring render_synopsis's wrap for the MCP form. + cased). A repeatable value option (Click ``multiple``) keeps the ``...`` + repetition notation — ``[--meta META ...]`` — so the page still shows it can be + passed more than once. + """ + opt = _synopsis_opt(param.opts) + if param.secondary_opts: + return f"[{opt} | {_synopsis_opt(param.secondary_opts)}]" + if param.is_flag: + return f"[{opt}]" + metavar = param.name.upper() + inner = f"{opt} {metavar} ..." if param.multiple else f"{opt} {metavar}" + return f"[{inner}]" + + +def render_cli_synopsis(command_path: str, command: ClickCommand) -> str: + """Render a section-1 page's shell SYNOPSIS from a resolved Click command. + + Positional arguments come first in declaration order (bare when required, + bracketed when optional), then every public option as a bracketed token (see + _synopsis_option_token). Options the CLI rejects in combination + (MUTUALLY_EXCLUSIVE_OPTIONS) collapse to a single ``[--json | --plain]`` + alternative at the first member's position rather than reading as freely + combinable. Lines wrap at the code block's width with continuations aligned + under the command name — mirroring render_synopsis's wrap for the MCP form. """ tokens: list[str] = [] for param in command.params: @@ -357,16 +420,31 @@ def render_cli_synopsis(command_path: str, command: Any) -> str: continue metavar = param.name.upper() tokens.append(metavar if param.required else f"[{metavar}]") - for param in command.params: - if param.param_type_name != "option" or getattr(param, "hidden", False): - continue - opt = _synopsis_opt(param.opts) - if param.secondary_opts: - tokens.append(f"[{opt} | {_synopsis_opt(param.secondary_opts)}]") - elif param.is_flag: - tokens.append(f"[{opt}]") + + options = [ + param for param in command.params if param.param_type_name == "option" and not param.hidden + ] + # A mutex pair renders as one grouped token only when both members are actually + # public options on this command; map each present member's long form to its pair. + present_longs = {_synopsis_opt(param.opts): param for param in options} + grouped: dict[str, tuple[str, ...]] = { + long: pair + for pair in MUTUALLY_EXCLUSIVE_OPTIONS + if set(pair) <= present_longs.keys() + for long in pair + } + emitted_pairs: set[tuple[str, ...]] = set() + for param in options: + pair = grouped.get(_synopsis_opt(param.opts)) + if pair is not None: + # Emit the whole group once, at its first member, in the pair's fixed + # order; skip the remaining members so it is not repeated. + if pair in emitted_pairs: + continue + emitted_pairs.add(pair) + tokens.append("[" + " | ".join(pair) + "]") else: - tokens.append(f"[{opt} {param.name.upper()}]") + tokens.append(_synopsis_option_token(param)) prefix = f"bm {command_path}" indent = " " * (len(prefix) + 1) @@ -388,7 +466,7 @@ def render_cli_synopsis(command_path: str, command: Any) -> str: return "\n".join(lines) -def _option_default_note(param: Any) -> str | None: +def _option_default_note(param: ClickParam) -> str | None: """The ``default: ...`` note for an option bullet, or None when there is none. A boolean pair reports which flag is on by default (``default: --frontmatter``); @@ -405,7 +483,7 @@ def _option_default_note(param: Any) -> str | None: return f"default: {_default_literal(default)}" -def render_options(command: Any) -> str: +def render_options(command: ClickCommand) -> str: """Render a section-1 page's ## OPTIONS body from a resolved Click command. Every public option is one bullet, in declaration order (the order --help @@ -419,7 +497,7 @@ def render_options(command: Any) -> str: """ bullets: list[str] = [] for param in command.params: - if param.param_type_name != "option" or getattr(param, "hidden", False): + if param.param_type_name != "option" or param.hidden: continue opts_display = ", ".join(_order_opts(param.opts)) if param.secondary_opts: diff --git a/src/basic_memory/man/man1/apropos(1).md b/src/basic_memory/man/man1/apropos(1).md index 441805116..aa8325543 100644 --- a/src/basic_memory/man/man1/apropos(1).md +++ b/src/basic_memory/man/man1/apropos(1).md @@ -16,8 +16,8 @@ generated: cli ## SYNOPSIS ``` -bm man apropos QUERY [--project PROJECT] [--json] [--plain] [--local] - [--cloud] +bm man apropos QUERY [--project PROJECT] [--json | --plain] + [--local | --cloud] ``` ## DESCRIPTION diff --git a/src/basic_memory/man/man1/cat(1).md b/src/basic_memory/man/man1/cat(1).md index c6f6e49bd..5378b27d6 100644 --- a/src/basic_memory/man/man1/cat(1).md +++ b/src/basic_memory/man/man1/cat(1).md @@ -17,9 +17,9 @@ generated: cli ``` bm cat IDENTIFIER [--lines LINES] [--section SECTION] - [--max-tokens MAX_TOKENS] [--frontmatter | --no-frontmatter] [--json] - [--plain] [--project PROJECT] [--project-id PROJECT_ID] [--local] - [--cloud] + [--max-tokens MAX_TOKENS] [--frontmatter | --no-frontmatter] + [--json | --plain] [--project PROJECT] [--project-id PROJECT_ID] + [--local | --cloud] ``` ## DESCRIPTION diff --git a/src/basic_memory/man/man1/find(1).md b/src/basic_memory/man/man1/find(1).md index 7c87630be..a0319bdac 100644 --- a/src/basic_memory/man/man1/find(1).md +++ b/src/basic_memory/man/man1/find(1).md @@ -17,9 +17,9 @@ generated: cli ``` bm find [PATH] [--name NAME] [--depth DEPTH] [--page PAGE] - [--page-size PAGE_SIZE] [--meta META] [--fields FIELDS] [--json] - [--plain] [--project PROJECT] [--project-id PROJECT_ID] [--local] - [--cloud] + [--page-size PAGE_SIZE] [--meta META ...] [--fields FIELDS] + [--json | --plain] [--project PROJECT] [--project-id PROJECT_ID] + [--local | --cloud] ``` ## DESCRIPTION diff --git a/src/basic_memory/man/man1/grep(1).md b/src/basic_memory/man/man1/grep(1).md index 3784c6a6a..bf6cee78d 100644 --- a/src/basic_memory/man/man1/grep(1).md +++ b/src/basic_memory/man/man1/grep(1).md @@ -18,8 +18,8 @@ generated: cli ``` bm grep PATTERN [--literal] [--context-lines CONTEXT_LINES] [--max-matches MAX_MATCHES] [--page PAGE] [--page-size PAGE_SIZE] - [--json] [--plain] [--project PROJECT] [--project-id PROJECT_ID] - [--local] [--cloud] + [--json | --plain] [--project PROJECT] [--project-id PROJECT_ID] + [--local | --cloud] ``` ## DESCRIPTION diff --git a/src/basic_memory/man/man1/head(1).md b/src/basic_memory/man/man1/head(1).md index 86007e7d4..870bcd6b4 100644 --- a/src/basic_memory/man/man1/head(1).md +++ b/src/basic_memory/man/man1/head(1).md @@ -16,9 +16,9 @@ generated: cli ## SYNOPSIS ``` -bm head IDENTIFIER [--lines N] [--frontmatter | --no-frontmatter] [--json] - [--plain] [--project PROJECT] [--project-id PROJECT_ID] [--local] - [--cloud] +bm head IDENTIFIER [--lines N] [--frontmatter | --no-frontmatter] + [--json | --plain] [--project PROJECT] [--project-id PROJECT_ID] + [--local | --cloud] ``` ## DESCRIPTION diff --git a/src/basic_memory/man/man1/ls(1).md b/src/basic_memory/man/man1/ls(1).md index fab405926..c5d7cdced 100644 --- a/src/basic_memory/man/man1/ls(1).md +++ b/src/basic_memory/man/man1/ls(1).md @@ -16,8 +16,8 @@ generated: cli ## SYNOPSIS ``` -bm ls [PATH] [--page PAGE] [--page-size PAGE_SIZE] [--json] [--plain] - [--project PROJECT] [--project-id PROJECT_ID] [--local] [--cloud] +bm ls [PATH] [--page PAGE] [--page-size PAGE_SIZE] [--json | --plain] + [--project PROJECT] [--project-id PROJECT_ID] [--local | --cloud] ``` ## DESCRIPTION diff --git a/src/basic_memory/man/man1/tail(1).md b/src/basic_memory/man/man1/tail(1).md index 869fb3e73..e601f5cd3 100644 --- a/src/basic_memory/man/man1/tail(1).md +++ b/src/basic_memory/man/man1/tail(1).md @@ -16,8 +16,8 @@ generated: cli ## SYNOPSIS ``` -bm tail [--timeframe TIMEFRAME] [--lines N] [--json] [--plain] - [--project PROJECT] [--project-id PROJECT_ID] [--local] [--cloud] +bm tail [--timeframe TIMEFRAME] [--lines N] [--json | --plain] + [--project PROJECT] [--project-id PROJECT_ID] [--local | --cloud] ``` ## DESCRIPTION diff --git a/src/basic_memory/man/man1/tree(1).md b/src/basic_memory/man/man1/tree(1).md index 694a47faf..73c2f0605 100644 --- a/src/basic_memory/man/man1/tree(1).md +++ b/src/basic_memory/man/man1/tree(1).md @@ -17,8 +17,8 @@ generated: cli ``` bm tree [PATH] [--name NAME] [--depth DEPTH] [--page PAGE] - [--page-size PAGE_SIZE] [--json] [--plain] [--project PROJECT] - [--project-id PROJECT_ID] [--local] [--cloud] + [--page-size PAGE_SIZE] [--json | --plain] [--project PROJECT] + [--project-id PROJECT_ID] [--local | --cloud] ``` ## DESCRIPTION diff --git a/tests/test_man_pages.py b/tests/test_man_pages.py index c20d1305e..ea3c93316 100644 --- a/tests/test_man_pages.py +++ b/tests/test_man_pages.py @@ -4,6 +4,7 @@ import importlib.util import re +from dataclasses import dataclass, field from pathlib import Path import pytest @@ -471,6 +472,86 @@ def test_render_options_preserves_aliases_and_boolean_pairs() -> None: assert "- **--frontmatter / --no-frontmatter** (default: --frontmatter)" in render_options(cat) +def test_render_cli_synopsis_groups_mutually_exclusive_options() -> None: + # --json/--plain and --local/--cloud are rejected in combination by the CLI + # (test_cli_man_lookup asserts exit 1 for both pairs), so the SYNOPSIS must show + # each pair as a single `|` alternative, not two freely-combinable tokens. + apropos_path, apropos = _cli_command(find_page(PageRef("apropos", 1))) + synopsis = render_cli_synopsis(apropos_path, apropos) + assert "[--json | --plain]" in synopsis + assert "[--local | --cloud]" in synopsis + # never the flattened, freely-combinable spelling the constraint forbids + for flattened in ("[--json]", "[--plain]", "[--local]", "[--cloud]"): + assert flattened not in synopsis + # --project/--project-id are NOT mutually exclusive (--project-id takes + # precedence), so they stay separate tokens rather than being grouped. + _, find = _cli_command(find_page(PageRef("find", 1))) + find_synopsis = render_cli_synopsis("find", find) + assert "[--project PROJECT]" in find_synopsis + assert "[--project-id PROJECT_ID]" in find_synopsis + assert "--project |" not in find_synopsis + + +def test_render_cli_synopsis_keeps_repeatable_options_repeatable() -> None: + # find --meta is multiple=True: the SYNOPSIS keeps the `...` repetition notation + # so the page still shows the option can be passed more than once. + _, find = _cli_command(find_page(PageRef("find", 1))) + synopsis = render_cli_synopsis("find", find) + assert "[--meta META ...]" in synopsis + # a non-repeatable value option carries no repetition notation + assert "[--fields FIELDS]" in synopsis + assert "[--fields FIELDS ...]" not in synopsis + + +@dataclass +class _FakeClickParam: + """A structural stand-in for a Click parameter (the ClickParam Protocol). + + Lets the visibility branch be exercised without a hidden option in the live + CLI, and pins that ``param.hidden`` is read directly — a param shape missing it + would raise rather than default to public, per the fail-fast contract. + """ + + param_type_name: str + name: str + opts: list[str] + secondary_opts: list[str] = field(default_factory=list) + required: bool = False + is_flag: bool = False + multiple: bool = False + hidden: bool = False + default: object = None + help: str | None = None + + +@dataclass +class _FakeClickCommand: + params: list[_FakeClickParam] + + +def test_render_excludes_hidden_options_from_synopsis_and_options() -> None: + kept = _FakeClickParam("option", "keep", ["--keep"], is_flag=True, help="kept") + secret = _FakeClickParam("option", "secret", ["--secret"], is_flag=True, hidden=True, help="x") + command = _FakeClickCommand([kept, secret]) + + synopsis = render_cli_synopsis("t", command) + assert "[--keep]" in synopsis + assert "--secret" not in synopsis + + options = render_options(command) + assert "- **--keep**" in options + assert "--secret" not in options + + +def test_render_options_is_not_grouped_for_mutually_exclusive_pairs() -> None: + # The mutex grouping is a SYNOPSIS-only concern: OPTIONS still documents every + # option as its own bullet, so each flag keeps its own description. + apropos_path, apropos = _cli_command(find_page(PageRef("apropos", 1))) + options = render_options(apropos) + for flag in ("--json", "--plain", "--local", "--cloud"): + assert f"- **{flag}**" in options + + def test_replace_options_touches_only_the_options_block() -> None: page = ( "# t\n\n## SYNOPSIS\n\n```\nbm t\n```\n\n"