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

1"""Shell Integration — completion scripts and man pages (FE-06).""" 

2 

3from __future__ import annotations 

4 

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 

13 

14import click 

15 

16try: 

17 __version__ = _get_version("apcore-cli") 

18except PackageNotFoundError: 

19 __version__ = "unknown" 

20 

21 

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) 

25 

26 

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 ) 

89 

90 

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 ) 

167 

168 

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 ) 

228 

229 

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]" 

234 

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) 

254 

255 

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" 

262 

263 sections: list[str] = [] 

264 sections.append(f'.TH "{title}" "1" "{today}" "{pkg_label}" "{manual_label}"') 

265 

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}") 

271 

272 sections.append(".SH SYNOPSIS") 

273 sections.append(_build_synopsis(command, prog_name, command_name)) 

274 

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("-", "\\-")) 

279 

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}.") 

295 

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.") 

320 

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}") 

336 

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)) 

345 

346 return "\n".join(sections) 

347 

348 

349def _roff_escape(s: str) -> str: 

350 """Escape a string for roff output.""" 

351 return s.replace("\\", "\\\\").replace("-", "\\-").replace("'", "\\(aq") 

352 

353 

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. 

362 

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] = [] 

369 

370 s.append(f'.TH "{prog_name.upper()}" "1" "{today}" "{prog_name} {version}" "{prog_name} Manual"') 

371 

372 s.append(".SH NAME") 

373 s.append(f"{prog_name} \\- {_roff_escape(desc)}") 

374 

375 s.append(".SH SYNOPSIS") 

376 s.append(f"\\fB{prog_name}\\fR [\\fIglobal\\-options\\fR] \\fIcommand\\fR [\\fIcommand\\-options\\fR]") 

377 

378 s.append(".SH DESCRIPTION") 

379 s.append(_roff_escape(desc)) 

380 

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])) 

393 

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 

404 

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)) 

410 

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") 

424 

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") 

453 

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).") 

465 

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}") 

481 

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") 

486 

487 return "\n".join(s) 

488 

489 

490def _render_man_page(roff: str) -> None: 

491 """Render a roff man page to stdout. 

492 

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 

501 

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 

527 

528 # Fallback: raw roff output 

529 sys.stdout.write(roff) 

530 

531 

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. 

540 

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. 

544 

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. 

549 

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 ) 

567 

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) 

574 

575 

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).""" 

578 

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. 

584 

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]()) 

598 

599 _ = completion_cmd 

600 

601 

602def register_shell_commands(cli: click.Group, prog_name: str = "apcore-cli") -> None: 

603 """Legacy wrapper — registers ``completion`` and ``man`` on the given group. 

604 

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) 

611 

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. 

617 

618 \b 

619 View immediately: 

620 PROG man list | man - 

621 PROG man describe | col -bx | less 

622 

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) 

631 

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 

635 

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) 

640 

641 roff = _generate_man_page(command, cmd, resolved_prog) 

642 click.echo(roff)