"""Generate ``docs/snippets/cli-commands.mdx`` from the Typer app. Walks the ``honcho`` Typer app and emits a Mintlify snippet using native Mintlify components: ```` / ```` for subcommand grouping and ```` 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 ```` 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"" if body: return [open_tag, f" {body}", ""] 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''] 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("") 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("") for sub_name in sorted(cmd.commands): lines.extend( _render_accordion(cmd.commands[sub_name], path + [sub_name]) ) lines.append("") 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())