262 lines
7.9 KiB
Python
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())
|