honcho/honcho-cli/scripts/generate_cli_docs.py

262 lines
7.9 KiB
Python

"""Generate ``docs/snippets/cli-commands.mdx`` from the Typer app.
Walks the ``honcho`` Typer app and emits a Mintlify snippet using native
Mintlify components: ``<AccordionGroup>`` / ``<Accordion>`` for subcommand
grouping and ``<ParamField>`` for each argument and option. The output is a
single snippet included by ``docs/v3/documentation/reference/cli.mdx``.
Usage::
uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py
# Or as a drift check (non-zero exit if the committed snippet is stale):
uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py --check
"""
from __future__ import annotations
import sys
from argparse import ArgumentParser
from pathlib import Path
import click
import typer.main
from honcho_cli.main import app
REPO_ROOT = Path(__file__).resolve().parents[2]
OUTPUT = REPO_ROOT / "docs" / "snippets" / "cli-commands.mdx"
HEADER = """{/*
GENERATED by honcho-cli/scripts/generate_cli_docs.py — do not edit.
Re-generate with: uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py
Source of truth: honcho-cli/src/honcho_cli/commands/
*/}
"""
# Documented once in cli.mdx's Configuration table. Skip at the per-command
# level so each Accordion only shows options specific to that subcommand.
GLOBAL_OPTIONS: set[tuple[str, str]] = {
("--workspace", "Override workspace ID"),
("--peer", "Override peer ID"),
("--session", "Override session ID"),
("--json", "Force JSON output"),
}
def _escape_mdx(text: str) -> str:
"""Escape MDX-sensitive characters in prose so Mintlify's parser doesn't
mistake ``{...}`` for a JSX expression or ``<x>`` for a JSX tag."""
return (
text.replace("\\", "\\\\")
.replace("{", "\\{")
.replace("}", "\\}")
.replace("<", "\\<")
)
def _attr(value: str) -> str:
"""Escape a string for use inside a JSX double-quoted attribute value."""
return value.replace("\\", "\\\\").replace('"', "'")
def _long_opt(param: click.Option) -> str | None:
return next((o for o in param.opts if o.startswith("--")), None)
def _short_opt(param: click.Option) -> str | None:
return next(
(o for o in param.opts if o.startswith("-") and not o.startswith("--")),
None,
)
def _is_global(param: click.Parameter) -> bool:
if not isinstance(param, click.Option) or not param.help:
return False
return (_long_opt(param), param.help) in GLOBAL_OPTIONS
def _param_type(param: click.Parameter) -> str:
if isinstance(param, click.Option) and param.is_flag:
return "boolean"
if isinstance(param.type, click.Choice):
return "string"
name = getattr(param.type, "name", "")
if name in ("integer", "int"):
return "number"
if name in ("float", "decimal"):
return "number"
if name == "boolean":
return "boolean"
return "string"
def _param_path(param: click.Parameter) -> str:
if isinstance(param, click.Argument):
return param.name or ""
return _long_opt(param) or (param.opts[0] if param.opts else "")
def _param_required(param: click.Parameter) -> bool:
if isinstance(param, click.Argument):
return param.required
if isinstance(param, click.Option):
return bool(param.required)
return False
def _default_attr(param: click.Parameter) -> str | None:
default = param.default
if default is None or default is False or callable(default):
return None
if isinstance(default, (list, tuple)) and not default:
return None
if default is True:
return "true"
return _attr(str(default))
def _ensure_period(text: str) -> str:
return text if text.endswith((".", "?", "!", ":")) else text + "."
def _param_body(param: click.Parameter) -> str:
parts: list[str] = []
if isinstance(param, click.Option):
if param.help:
parts.append(_ensure_period(_escape_mdx(param.help.strip())))
short = _short_opt(param)
if short:
parts.append(f"Short alias: `{short}`.")
if param.secondary_opts:
neg = " / ".join(f"`{o}`" for o in param.secondary_opts)
parts.append(f"Negate with {neg}.")
if isinstance(param.type, click.Choice):
choices = ", ".join(f"`{c}`" for c in param.type.choices)
parts.append(f"One of: {choices}.")
return " ".join(parts)
def _render_param(param: click.Parameter) -> list[str]:
props = [
f'path="{_attr(_param_path(param))}"',
f'type="{_param_type(param)}"',
]
if _param_required(param):
props.append("required")
default_attr = _default_attr(param)
if default_attr is not None:
props.append(f'default="{default_attr}"')
body = _param_body(param).strip()
open_tag = f"<ParamField {' '.join(props)}>"
if body:
return [open_tag, f" {body}", "</ParamField>"]
return [open_tag.replace(">", " />")]
def _params_of(
cmd: click.Command, *, strip_globals: bool
) -> list[click.Parameter]:
args = [p for p in cmd.params if isinstance(p, click.Argument)]
opts = [
p
for p in cmd.params
if isinstance(p, click.Option)
and not p.hidden
and not (strip_globals and _is_global(p))
]
return args + opts
def _invocation_line(cmd: click.Command, path: list[str]) -> str:
args = [p for p in cmd.params if isinstance(p, click.Argument)]
parts = [" ".join(path)]
for a in args:
placeholder = f"<{a.name}>"
if not a.required:
placeholder = f"[{placeholder}]"
parts.append(placeholder)
return " ".join(parts)
def _render_accordion(cmd: click.Command, path: list[str]) -> list[str]:
lines = [f'<Accordion title="{_attr(path[-1])}">']
if cmd.help:
lines.append(_escape_mdx(cmd.help.strip()))
lines.append("")
lines.append("```bash")
lines.append(_invocation_line(cmd, path))
lines.append("```")
lines.append("")
for p in _params_of(cmd, strip_globals=True):
lines.extend(_render_param(p))
lines.append("</Accordion>")
return lines
def _render_top(cmd: click.Command, path: list[str]) -> list[str]:
lines = [f"## {' '.join(path)}", ""]
if cmd.help:
lines.append(_escape_mdx(cmd.help.strip()))
lines.append("")
if isinstance(cmd, click.Group) and cmd.commands:
lines.append("<AccordionGroup>")
for sub_name in sorted(cmd.commands):
lines.extend(
_render_accordion(cmd.commands[sub_name], path + [sub_name])
)
lines.append("</AccordionGroup>")
lines.append("")
return lines
lines.append("```bash")
lines.append(_invocation_line(cmd, path))
lines.append("```")
lines.append("")
for p in _params_of(cmd, strip_globals=True):
lines.extend(_render_param(p))
lines.append("")
return lines
def build() -> str:
root: click.Command = typer.main.get_command(app)
if not isinstance(root, click.Group):
raise SystemExit("Expected root command to be a Group")
body: list[str] = []
for name in sorted(root.commands):
body.extend(_render_top(root.commands[name], ["honcho", name]))
return HEADER + "\n".join(body).rstrip("\n") + "\n"
def main() -> int:
parser = ArgumentParser()
parser.add_argument(
"--check",
action="store_true",
help="Exit non-zero if the committed snippet differs from generated output.",
)
ns = parser.parse_args()
generated = build()
if ns.check:
current = OUTPUT.read_text() if OUTPUT.exists() else ""
if current != generated:
print(
f"::error::{OUTPUT} is stale. Re-run without --check to regenerate.",
file=sys.stderr,
)
return 1
return 0
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(generated)
print(f"Wrote {OUTPUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())