Coverage for src / apcore_cli / shell.py: 83%
254 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-26 10:23 +0800
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-26 10:23 +0800
1"""Shell Integration — completion scripts and man pages (FE-06)."""
3from __future__ import annotations
5import os
6import re
7import shlex
8import subprocess
9import sys
10from datetime import date
11from importlib.metadata import PackageNotFoundError
12from importlib.metadata import version as _get_version
14import click
16try:
17 __version__ = _get_version("apcore-cli")
18except PackageNotFoundError:
19 __version__ = "unknown"
22def _make_function_name(prog_name: str) -> str:
23 """Convert a prog_name like 'my-tool' to a valid shell identifier '_my_tool'."""
24 return "_" + re.sub(r"[^a-zA-Z0-9]", "_", prog_name)
27def _generate_bash_completion(prog_name: str) -> str:
28 fn = _make_function_name(prog_name)
29 quoted = shlex.quote(prog_name)
30 module_list_cmd = (
31 f"{quoted} list --format json 2>/dev/null"
32 ' | python3 -c "import sys,json;'
33 "[print(m['id']) for m in json.load(sys.stdin)]\" 2>/dev/null"
34 )
35 # Command to extract group names and top-level (ungrouped) module IDs
36 groups_and_top_cmd = (
37 f"{quoted} list --format json 2>/dev/null"
38 ' | python3 -c "'
39 "import sys,json\n"
40 "ids=[m['id'] for m in json.load(sys.stdin)]\n"
41 "groups=set()\n"
42 "top=[]\n"
43 "for i in ids:\n"
44 " if '.' in i: groups.add(i.split('.')[0])\n"
45 " else: top.append(i)\n"
46 "print(' '.join(sorted(groups)+sorted(top)))\n"
47 '" 2>/dev/null'
48 )
49 # Command to list sub-commands for a given group (uses shell variable $grp)
50 group_cmds_cmd = (
51 f"{quoted} list --format json 2>/dev/null"
52 ' | python3 -c "'
53 "import sys,json,os\n"
54 "g=os.environ['_APCORE_GRP']\n"
55 "ids=[m['id'] for m in json.load(sys.stdin)]\n"
56 "for i in ids:\n"
57 " if '.' in i and i.split('.')[0]==g: print(i.split('.',1)[1])\n"
58 '" 2>/dev/null'
59 )
60 return (
61 f"{fn}() {{\n"
62 " local cur prev\n"
63 " COMPREPLY=()\n"
64 ' cur="${COMP_WORDS[COMP_CWORD]}"\n'
65 ' prev="${COMP_WORDS[COMP_CWORD-1]}"\n'
66 "\n"
67 " if [[ ${COMP_CWORD} -eq 1 ]]; then\n"
68 f" local all_ids=$({groups_and_top_cmd})\n"
69 ' local builtins="completion describe exec init list man"\n'
70 ' COMPREPLY=( $(compgen -W "${builtins} ${all_ids}" -- ${cur}) )\n'
71 " return 0\n"
72 " fi\n"
73 "\n"
74 f' if [[ "${{COMP_WORDS[1]}}" == "exec" && ${{COMP_CWORD}} -eq 2 ]]; then\n'
75 f" local modules=$({module_list_cmd})\n"
76 ' COMPREPLY=( $(compgen -W "${modules}" -- ${cur}) )\n'
77 " return 0\n"
78 " fi\n"
79 "\n"
80 " if [[ ${COMP_CWORD} -eq 2 ]]; then\n"
81 ' local grp="${COMP_WORDS[1]}"\n'
82 f' local cmds=$(export _APCORE_GRP="$grp"; {group_cmds_cmd})\n'
83 ' COMPREPLY=( $(compgen -W "${cmds}" -- ${cur}) )\n'
84 " return 0\n"
85 " fi\n"
86 "}\n"
87 f"complete -F {fn} {quoted}\n"
88 )
91def _generate_zsh_completion(prog_name: str) -> str:
92 fn = _make_function_name(prog_name)
93 quoted = shlex.quote(prog_name)
94 module_list_cmd = (
95 f"{quoted} list --format json 2>/dev/null"
96 ' | python3 -c "import sys,json;'
97 "[print(m['id']) for m in json.load(sys.stdin)]\" 2>/dev/null"
98 )
99 # Command to extract group names and top-level module IDs for position 1
100 groups_and_top_cmd = (
101 f"{quoted} list --format json 2>/dev/null"
102 ' | python3 -c "'
103 "import sys,json\n"
104 "ids=[m['id'] for m in json.load(sys.stdin)]\n"
105 "groups=set()\n"
106 "top=[]\n"
107 "for i in ids:\n"
108 " if '.' in i: groups.add(i.split('.')[0])\n"
109 " else: top.append(i)\n"
110 "print(' '.join(sorted(groups)+sorted(top)))\n"
111 '" 2>/dev/null'
112 )
113 # Command to list sub-commands for a given group ($1 is the group name)
114 group_cmds_cmd = (
115 f"{quoted} list --format json 2>/dev/null"
116 ' | python3 -c "'
117 "import sys,json,os\n"
118 "g=os.environ['_APCORE_GRP']\n"
119 "ids=[m['id'] for m in json.load(sys.stdin)]\n"
120 "for i in ids:\n"
121 " if '.' in i and i.split('.')[0]==g: print(i.split('.',1)[1])\n"
122 '" 2>/dev/null'
123 )
124 return (
125 f"#compdef {prog_name}\n"
126 "\n"
127 f"{fn}() {{\n"
128 " local -a commands groups_and_top\n"
129 " commands=(\n"
130 " 'exec:Execute an apcore module'\n"
131 " 'list:List available modules'\n"
132 " 'describe:Show module metadata and schema'\n"
133 " 'completion:Generate shell completion script'\n"
134 " 'init:Scaffolding commands'\n"
135 " 'man:Generate man page'\n"
136 " )\n"
137 "\n"
138 " _arguments -C \\\n"
139 " '1:command:->command' \\\n"
140 " '*::arg:->args'\n"
141 "\n"
142 ' case "$state" in\n'
143 " command)\n"
144 f" groups_and_top=($({groups_and_top_cmd}))\n"
145 f" _describe -t commands '{prog_name} commands' commands\n"
146 " compadd -a groups_and_top\n"
147 " ;;\n"
148 " args)\n"
149 ' case "${words[1]}" in\n'
150 " exec)\n"
151 " local modules\n"
152 f" modules=($({module_list_cmd}))\n"
153 " compadd -a modules\n"
154 " ;;\n"
155 " *)\n"
156 " local -a group_cmds\n"
157 f' group_cmds=($(export _APCORE_GRP="${{words[1]}}"; {group_cmds_cmd}))\n'
158 " compadd -a group_cmds\n"
159 " ;;\n"
160 " esac\n"
161 " ;;\n"
162 " esac\n"
163 "}\n"
164 "\n"
165 f"compdef {fn} {quoted}\n"
166 )
169def _generate_fish_completion(prog_name: str) -> str:
170 quoted = shlex.quote(prog_name)
171 module_list_cmd = (
172 f"{quoted} list --format json 2>/dev/null"
173 ' | python3 -c \\"import sys,json;'
174 "[print(m['id']) for m in json.load(sys.stdin)]\\\" 2>/dev/null"
175 )
176 # Fish command to extract group names and top-level module IDs
177 groups_and_top_cmd = (
178 f"{quoted} list --format json 2>/dev/null"
179 ' | python3 -c \\"'
180 "import sys,json\\n"
181 "ids=[m['id'] for m in json.load(sys.stdin)]\\n"
182 "groups=set()\\n"
183 "top=[]\\n"
184 "for i in ids:\\n"
185 " if '.' in i: groups.add(i.split('.')[0])\\n"
186 " else: top.append(i)\\n"
187 "print('\\\\n'.join(sorted(groups)+sorted(top)))\\n"
188 '\\" 2>/dev/null'
189 )
190 # Fish command to list sub-commands for a given group
191 # Uses $argv[1] as the group name passed by the function
192 group_cmds_fish_fn = (
193 f"function __apcore_group_cmds\n"
194 f" set -l grp $argv[1]\n"
195 f" {quoted} list --format json 2>/dev/null"
196 ' | python3 -c \\"'
197 "import sys,json,os\\n"
198 "g=os.environ['_APCORE_GRP']\\n"
199 "ids=[m['id'] for m in json.load(sys.stdin)]\\n"
200 "for i in ids:\\n"
201 " if '.' in i and i.split('.')[0]==g: print(i.split('.',1)[1])\\n"
202 '\\" 2>/dev/null\n'
203 "end\n"
204 )
205 return (
206 f"# Fish completions for {prog_name}\n"
207 f"\n"
208 f"{group_cmds_fish_fn}"
209 f"\n"
210 f'complete -c {quoted} -n "__fish_use_subcommand"'
211 ' -a exec -d "Execute an apcore module"\n'
212 f'complete -c {quoted} -n "__fish_use_subcommand"'
213 ' -a list -d "List available modules"\n'
214 f'complete -c {quoted} -n "__fish_use_subcommand"'
215 ' -a describe -d "Show module metadata and schema"\n'
216 f'complete -c {quoted} -n "__fish_use_subcommand"'
217 ' -a completion -d "Generate shell completion script"\n'
218 f'complete -c {quoted} -n "__fish_use_subcommand"'
219 ' -a init -d "Scaffolding commands"\n'
220 f'complete -c {quoted} -n "__fish_use_subcommand"'
221 ' -a man -d "Generate man page"\n'
222 f'complete -c {quoted} -n "__fish_use_subcommand"'
223 f' -a "({groups_and_top_cmd})" -d "Module group or command"\n'
224 "\n"
225 f'complete -c {quoted} -n "__fish_seen_subcommand_from exec"'
226 f' -a "({module_list_cmd})"\n'
227 )
230def _build_synopsis(command: click.Command | None, prog_name: str, command_name: str) -> str:
231 """Build a synopsis line reflecting actual options and arguments."""
232 if command is None:
233 return f"\\fB{prog_name} {command_name}\\fR [OPTIONS]"
235 parts = [f"\\fB{prog_name} {command_name}\\fR"]
236 for param in command.params:
237 if isinstance(param, click.Option):
238 flag = param.opts[0]
239 if param.is_flag:
240 parts.append(f"[{flag}]")
241 elif param.required:
242 type_upper = (param.type.name if hasattr(param.type, "name") else "VALUE").upper()
243 parts.append(f"{flag} \\fI{type_upper}\\fR")
244 else:
245 type_upper = (param.type.name if hasattr(param.type, "name") else "VALUE").upper()
246 parts.append(f"[{flag} \\fI{type_upper}\\fR]")
247 elif isinstance(param, click.Argument):
248 meta = param.human_readable_name.upper()
249 if param.required:
250 parts.append(f"\\fI{meta}\\fR")
251 else:
252 parts.append(f"[\\fI{meta}\\fR]")
253 return " ".join(parts)
256def _generate_man_page(command_name: str, command: click.Command | None, prog_name: str) -> str:
257 """Generate a roff-formatted man page for a command."""
258 today = date.today().strftime("%Y-%m-%d")
259 title = f"{prog_name}-{command_name}".upper()
260 pkg_label = f"{prog_name} {__version__}"
261 manual_label = f"{prog_name} Manual"
263 sections: list[str] = []
264 sections.append(f'.TH "{title}" "1" "{today}" "{pkg_label}" "{manual_label}"')
266 sections.append(".SH NAME")
267 desc = (command.help or command_name) if command else command_name
268 # Collapse multi-line help to a single short phrase for NAME
269 name_desc = desc.split("\n")[0].rstrip(".")
270 sections.append(f"{prog_name}-{command_name} \\- {name_desc}")
272 sections.append(".SH SYNOPSIS")
273 sections.append(_build_synopsis(command, prog_name, command_name))
275 if command and command.help:
276 sections.append(".SH DESCRIPTION")
277 # Escape roff special chars in description
278 sections.append(command.help.replace("\\", "\\\\").replace("-", "\\-"))
280 if command and any(isinstance(p, click.Option) for p in command.params):
281 sections.append(".SH OPTIONS")
282 for param in command.params:
283 if isinstance(param, click.Option):
284 flag = ", ".join(param.opts)
285 type_name = param.type.name.upper() if hasattr(param.type, "name") else "VALUE"
286 sections.append(".TP")
287 if param.is_flag:
288 sections.append(f"\\fB{flag}\\fR")
289 else:
290 sections.append(f"\\fB{flag}\\fR \\fI{type_name}\\fR")
291 if param.help:
292 sections.append(param.help)
293 if param.default is not None and not param.is_flag:
294 sections.append(f"Default: {param.default}.")
296 sections.append(".SH ENVIRONMENT")
297 sections.append(".TP")
298 sections.append("\\fBAPCORE_EXTENSIONS_ROOT\\fR")
299 sections.append("Path to the apcore extensions directory. Overrides the default \\fI./extensions\\fR.")
300 sections.append(".TP")
301 sections.append("\\fBAPCORE_CLI_AUTO_APPROVE\\fR")
302 sections.append(
303 "Set to \\fB1\\fR to bypass approval prompts for modules that require human-in-the-loop confirmation."
304 )
305 sections.append(".TP")
306 sections.append("\\fBAPCORE_CLI_LOGGING_LEVEL\\fR")
307 sections.append(
308 "CLI-specific logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. "
309 "Takes priority over \\fBAPCORE_LOGGING_LEVEL\\fR. Default: WARNING."
310 )
311 sections.append(".TP")
312 sections.append("\\fBAPCORE_LOGGING_LEVEL\\fR")
313 sections.append(
314 "Global apcore logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. "
315 "Used as fallback when \\fBAPCORE_CLI_LOGGING_LEVEL\\fR is not set. Default: WARNING."
316 )
317 sections.append(".TP")
318 sections.append("\\fBAPCORE_AUTH_API_KEY\\fR")
319 sections.append("API key for authenticating with the apcore registry.")
321 sections.append(".SH EXIT CODES")
322 exit_codes = [
323 ("0", "Success."),
324 ("1", "Module execution error."),
325 ("2", "Invalid CLI input or missing argument."),
326 ("44", "Module not found, disabled, or failed to load."),
327 ("45", "Input failed JSON Schema validation."),
328 ("46", "Approval denied, timed out, or no interactive terminal available."),
329 ("47", "Configuration error (extensions directory not found or unreadable)."),
330 ("48", "Schema contains a circular \\fB$ref\\fR."),
331 ("77", "ACL denied — insufficient permissions for this module."),
332 ("130", "Execution cancelled by user (SIGINT / Ctrl\\-C)."),
333 ]
334 for code, meaning in exit_codes:
335 sections.append(f".TP\n\\fB{code}\\fR\n{meaning}")
337 sections.append(".SH SEE ALSO")
338 see_also = [
339 f"\\fB{prog_name}\\fR(1)",
340 f"\\fB{prog_name}\\-list\\fR(1)",
341 f"\\fB{prog_name}\\-describe\\fR(1)",
342 f"\\fB{prog_name}\\-completion\\fR(1)",
343 ]
344 sections.append(", ".join(see_also))
346 return "\n".join(sections)
349def _roff_escape(s: str) -> str:
350 """Escape a string for roff output."""
351 return s.replace("\\", "\\\\").replace("-", "\\-").replace("'", "\\(aq")
354def build_program_man_page(
355 cli: click.Group,
356 prog_name: str,
357 version: str,
358 description: str | None = None,
359 docs_url: str | None = None,
360) -> str:
361 """Build a complete roff man page for the entire CLI program.
363 Covers all registered commands including downstream business commands
364 injected via GroupedModuleGroup.
365 """
366 today = date.today().isoformat()
367 desc = description or cli.help or f"{prog_name} CLI"
368 s: list[str] = []
370 s.append(f'.TH "{prog_name.upper()}" "1" "{today}" "{prog_name} {version}" "{prog_name} Manual"')
372 s.append(".SH NAME")
373 s.append(f"{prog_name} \\- {_roff_escape(desc)}")
375 s.append(".SH SYNOPSIS")
376 s.append(f"\\fB{prog_name}\\fR [\\fIglobal\\-options\\fR] \\fIcommand\\fR [\\fIcommand\\-options\\fR]")
378 s.append(".SH DESCRIPTION")
379 s.append(_roff_escape(desc))
381 # Global options
382 ctx = click.Context(cli, info_name=prog_name)
383 params = cli.get_params(ctx)
384 visible_params = [p for p in params if not getattr(p, "hidden", False) and p.name not in ("help", "version", "man")]
385 if visible_params:
386 s.append(".SH GLOBAL OPTIONS")
387 for p in visible_params:
388 record = p.get_help_record(ctx)
389 if record:
390 s.append(".TP")
391 s.append(f"\\fB{_roff_escape(record[0])}\\fR")
392 s.append(_roff_escape(record[1]))
394 # Commands
395 cmd_names = cli.list_commands(ctx)
396 if cmd_names:
397 s.append(".SH COMMANDS")
398 for name in sorted(cmd_names):
399 if name == "help":
400 continue
401 cmd = cli.get_command(ctx, name)
402 if cmd is None:
403 continue
405 cmd_desc = cmd.get_short_help_str() if cmd else ""
406 s.append(".TP")
407 s.append(f"\\fB{prog_name} {_roff_escape(name)}\\fR")
408 if cmd_desc:
409 s.append(_roff_escape(cmd_desc))
411 # Command options
412 sub_ctx = click.Context(cmd, info_name=name, parent=ctx)
413 sub_params = [
414 p for p in cmd.get_params(sub_ctx) if not getattr(p, "hidden", False) and p.name not in ("help",)
415 ]
416 for p in sub_params:
417 record = p.get_help_record(sub_ctx)
418 if record:
419 s.append(".RS")
420 s.append(".TP")
421 s.append(f"\\fB{_roff_escape(record[0])}\\fR")
422 s.append(_roff_escape(record[1]))
423 s.append(".RE")
425 # Nested subcommands (groups)
426 if isinstance(cmd, click.Group):
427 sub_names = cmd.list_commands(sub_ctx)
428 for sub_name in sorted(sub_names):
429 if sub_name == "help":
430 continue
431 sub_cmd = cmd.get_command(sub_ctx, sub_name)
432 if sub_cmd is None:
433 continue
434 sub_desc = sub_cmd.get_short_help_str() if sub_cmd else ""
435 s.append(".TP")
436 s.append(f"\\fB{prog_name} {_roff_escape(name)} {_roff_escape(sub_name)}\\fR")
437 if sub_desc:
438 s.append(_roff_escape(sub_desc))
439 nested_ctx = click.Context(sub_cmd, info_name=sub_name, parent=sub_ctx)
440 nested_params = [
441 p
442 for p in sub_cmd.get_params(nested_ctx)
443 if not getattr(p, "hidden", False) and p.name not in ("help",)
444 ]
445 for p in nested_params:
446 record = p.get_help_record(nested_ctx)
447 if record:
448 s.append(".RS")
449 s.append(".TP")
450 s.append(f"\\fB{_roff_escape(record[0])}\\fR")
451 s.append(_roff_escape(record[1]))
452 s.append(".RE")
454 # Environment
455 s.append(".SH ENVIRONMENT")
456 s.append(".TP")
457 s.append("\\fBAPCORE_EXTENSIONS_ROOT\\fR")
458 s.append("Path to the apcore extensions directory.")
459 s.append(".TP")
460 s.append("\\fBAPCORE_CLI_AUTO_APPROVE\\fR")
461 s.append("Set to \\fB1\\fR to bypass approval prompts.")
462 s.append(".TP")
463 s.append("\\fBAPCORE_CLI_LOGGING_LEVEL\\fR")
464 s.append("CLI\\-specific logging verbosity (DEBUG|INFO|WARNING|ERROR).")
466 # Exit codes
467 s.append(".SH EXIT CODES")
468 codes = [
469 ("0", "Success."),
470 ("1", "Module execution error."),
471 ("2", "Invalid input."),
472 ("44", "Module not found."),
473 ("45", "Schema validation error."),
474 ("46", "Approval denied or timed out."),
475 ("47", "Configuration error."),
476 ("77", "ACL denied."),
477 ("130", "Cancelled by user (SIGINT)."),
478 ]
479 for code, meaning in codes:
480 s.append(f".TP\n\\fB{code}\\fR\n{meaning}")
482 s.append(".SH SEE ALSO")
483 s.append(f"\\fB{prog_name} \\-\\-help \\-\\-verbose\\fR for full option list.")
484 if docs_url:
485 s.append(f".PP\nFull documentation at \\fI{_roff_escape(docs_url)}\\fR")
487 return "\n".join(s)
490def _render_man_page(roff: str) -> None:
491 """Render a roff man page to stdout.
493 When stdout is a TTY, attempts to render through mandoc or groff and
494 pipe through a pager for formatted display. When stdout is not a TTY
495 (piped or redirected), outputs raw roff for file redirection.
496 """
497 roff_bytes = roff.encode()
498 if not sys.stdout.isatty():
499 sys.stdout.write(roff)
500 return
502 # Try mandoc first (macOS/BSD), then groff
503 renderers = [
504 ["mandoc", "-a"],
505 ["groff", "-man", "-Tutf8"],
506 ]
507 for cmd in renderers:
508 try:
509 result = subprocess.run(
510 cmd,
511 input=roff_bytes,
512 capture_output=True,
513 )
514 except FileNotFoundError:
515 continue
516 if result.returncode == 0 and result.stdout:
517 pager = os.environ.get("PAGER", "less")
518 try:
519 subprocess.run(
520 [pager, "-R"],
521 input=result.stdout,
522 )
523 return
524 except FileNotFoundError:
525 # Pager not found — fall through to raw output
526 break
528 # Fallback: raw roff output
529 sys.stdout.write(roff)
532def configure_man_help(
533 cli: click.Group,
534 prog_name: str,
535 version: str,
536 description: str | None = None,
537 docs_url: str | None = None,
538) -> None:
539 """Configure --help --man support on a Click CLI group.
541 When --man is passed with --help, outputs a complete roff man page
542 covering all registered commands. Downstream projects call this once
543 to get man page generation for free.
545 .. note::
546 Call this **after** all commands are registered on ``cli``.
547 The argv pre-parse triggers immediate man page generation, so
548 commands added later will not appear in the output.
550 Usage:
551 configure_man_help(cli, "reach", "0.2.0", "ReachForge", "https://reachforge.dev/docs")
552 """
553 # Add --man as a hidden Click option.
554 # expose_value=False: Click must not pass this to the group callback,
555 # which has no 'man' parameter. The value is read directly from sys.argv
556 # via the pre-parse below.
557 cli.params.append(
558 click.Option(
559 ["--man"],
560 is_flag=True,
561 default=False,
562 hidden=True,
563 expose_value=False,
564 help="Output man page in roff format (use with --help).",
565 )
566 )
568 # Pre-parse: if both --help and --man in argv, generate man page and exit
569 args = sys.argv[1:]
570 if "--man" in args and ("--help" in args or "-h" in args):
571 roff = build_program_man_page(cli, prog_name, version, description, docs_url)
572 _render_man_page(roff)
573 sys.exit(0)
576def register_completion_command(apcli_group: click.Group, prog_name: str = "apcore-cli") -> None:
577 """Register the ``completion`` subcommand on the given group (FE-13)."""
579 @apcli_group.command("completion")
580 @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
581 @click.pass_context
582 def completion_cmd(ctx: click.Context, shell: str) -> None:
583 """Generate a shell completion script and print it to stdout.
585 \b
586 Install (add to your shell profile):
587 bash: eval "$(PROG completion bash)" or source <(PROG completion bash)
588 zsh: eval "$(PROG completion zsh)" or source <(PROG completion zsh)
589 fish: PROG completion fish | source
590 """
591 resolved = ctx.find_root().info_name or prog_name
592 generators = {
593 "bash": lambda: _generate_bash_completion(resolved),
594 "zsh": lambda: _generate_zsh_completion(resolved),
595 "fish": lambda: _generate_fish_completion(resolved),
596 }
597 click.echo(generators[shell]())
599 _ = completion_cmd
602def register_shell_commands(cli: click.Group, prog_name: str = "apcore-cli") -> None:
603 """Legacy wrapper — registers ``completion`` and ``man`` on the given group.
605 FE-13 canonical wiring attaches ``completion`` to the ``apcli`` group via
606 :func:`register_completion_command`; ``man`` remains at the root per
607 spec §4.1 (meta-commands stay at root). This shim preserves the pre-v0.7
608 flat shape for existing tests.
609 """
610 register_completion_command(cli, prog_name=prog_name)
612 @cli.command("man")
613 @click.argument("command")
614 @click.pass_context
615 def man_cmd(ctx: click.Context, command: str) -> None:
616 """Generate a roff man page for COMMAND and print it to stdout.
618 \b
619 View immediately:
620 PROG man list | man -
621 PROG man describe | col -bx | less
623 Install system-wide:
624 PROG man list > /usr/local/share/man/man1/PROG-list.1
625 mandb # (Linux) or /usr/libexec/makewhatis # (macOS)
626 """
627 parent = ctx.parent
628 if parent is None:
629 click.echo(f"Error: Unknown command '{command}'.", err=True)
630 sys.exit(2)
632 resolved_prog = ctx.find_root().info_name or prog_name
633 parent_group = parent.command
634 cmd = parent_group.commands.get(command) if isinstance(parent_group, click.Group) else None
636 known_builtins = {"completion", "describe", "exec", "init", "list", "man"}
637 if cmd is None and command not in known_builtins:
638 click.echo(f"Error: Unknown command '{command}'.", err=True)
639 sys.exit(2)
641 roff = _generate_man_page(command, cmd, resolved_prog)
642 click.echo(roff)