apcore-cli 0.3.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/CHANGELOG.md +14 -0
  2. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/PKG-INFO +11 -4
  3. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/README.md +10 -3
  4. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/pyproject.toml +1 -1
  5. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/__main__.py +22 -2
  6. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/cli.py +48 -7
  7. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/shell.py +181 -0
  8. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_cli.py +53 -0
  9. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_shell.py +104 -0
  10. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/.github/CODEOWNERS +0 -0
  11. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/.github/copilot-ignore +0 -0
  12. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/.github/workflows/ci.yml +0 -0
  13. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/.gitignore +0 -0
  14. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/.gitmessage +0 -0
  15. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/.pre-commit-config.yaml +0 -0
  16. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/CLAUDE.md +0 -0
  17. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/commands/ops.py +0 -0
  18. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/examples/extensions/math/add.py +0 -0
  19. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/examples/extensions/math/multiply.py +0 -0
  20. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/examples/extensions/sysutil/disk.py +0 -0
  21. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/examples/extensions/sysutil/env.py +0 -0
  22. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/examples/extensions/sysutil/info.py +0 -0
  23. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/examples/extensions/text/reverse.py +0 -0
  24. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/examples/extensions/text/upper.py +0 -0
  25. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/examples/extensions/text/wordcount.py +0 -0
  26. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/examples/run_examples.sh +0 -0
  27. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/approval-gate.md +0 -0
  28. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/config-resolver.md +0 -0
  29. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/core-dispatcher.md +0 -0
  30. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/discovery.md +0 -0
  31. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/grouped-commands.md +0 -0
  32. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/output-formatter.md +0 -0
  33. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/overview.md +0 -0
  34. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/schema-parser.md +0 -0
  35. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/security-manager.md +0 -0
  36. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/shell-integration.md +0 -0
  37. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/planning/state.json +0 -0
  38. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/__init__.py +0 -0
  39. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/_sandbox_runner.py +0 -0
  40. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/approval.py +0 -0
  41. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/config.py +0 -0
  42. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/discovery.py +0 -0
  43. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/display_helpers.py +0 -0
  44. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/init_cmd.py +0 -0
  45. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/output.py +0 -0
  46. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/ref_resolver.py +0 -0
  47. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/schema_parser.py +0 -0
  48. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/security/__init__.py +0 -0
  49. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/security/audit.py +0 -0
  50. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/security/auth.py +0 -0
  51. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/security/config_encryptor.py +0 -0
  52. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/src/apcore_cli/security/sandbox.py +0 -0
  53. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/__init__.py +0 -0
  54. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/conftest.py +0 -0
  55. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_approval.py +0 -0
  56. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_bugfixes.py +0 -0
  57. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_config.py +0 -0
  58. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_discovery.py +0 -0
  59. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_e2e.py +0 -0
  60. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_init_cmd.py +0 -0
  61. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_integration.py +0 -0
  62. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_output.py +0 -0
  63. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_ref_resolver.py +0 -0
  64. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_schema_parser.py +0 -0
  65. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_security/__init__.py +0 -0
  66. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_security/test_audit.py +0 -0
  67. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_security/test_auth.py +0 -0
  68. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_security/test_config_encryptor.py +0 -0
  69. {apcore_cli-0.3.1 → apcore_cli-0.4.0}/tests/test_security/test_sandbox.py +0 -0
@@ -5,6 +5,20 @@ All notable changes to apcore-cli (Python SDK) will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-03-29
9
+
10
+ ### Added
11
+ - **Verbose help mode** — Built-in apcore options (`--input`, `--yes`, `--large-input`, `--format`, `--sandbox`) are now hidden from `--help` output by default. Pass `--help --verbose` to display the full option list including built-in options.
12
+ - **Universal man page generation** — `build_program_man_page()` generates a complete roff man page covering all registered commands. `configure_man_help()` adds `--help --man` support to any Click CLI, enabling downstream projects to get man pages for free.
13
+ - **Documentation URL support** — `set_docs_url()` sets a base URL for online docs. Per-command help shows `Docs: {url}/commands/{name}`, man page SEE ALSO includes `Full documentation at {url}`. No default — disabled when not set.
14
+
15
+ ### Changed
16
+ - `build_module_command()` respects the global verbose help flag to control built-in option visibility.
17
+ - `--sandbox` is now always hidden from help (not yet implemented). Only four built-in options (`--input`, `--yes`, `--large-input`, `--format`) toggle with `--verbose`.
18
+ - Improved built-in option descriptions for clarity.
19
+
20
+ ---
21
+
8
22
  ## [0.3.1] - 2026-03-27
9
23
 
10
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apcore-cli
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Terminal adapter for apcore — execute AI-Perceivable modules from the command line
5
5
  Project-URL: Homepage, https://aiperceivable.com
6
6
  Project-URL: Repository, https://github.com/aiperceivable/apcore-cli-python
@@ -241,6 +241,8 @@ apcore-cli [OPTIONS] COMMAND [ARGS]
241
241
  | `--log-level` | `WARNING` | Logging: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
242
242
  | `--version` | | Show version and exit |
243
243
  | `--help` | | Show help and exit |
244
+ | `--verbose` | | Show hidden built-in options in `--help` output |
245
+ | `--man` | | Print man page to stdout (use with `--help`) |
244
246
 
245
247
  ### Built-in Commands
246
248
 
@@ -253,7 +255,7 @@ apcore-cli [OPTIONS] COMMAND [ARGS]
253
255
 
254
256
  ### Module Execution Options
255
257
 
256
- When executing a module (e.g. `apcore-cli math.add`), these built-in options are always available:
258
+ When executing a module (e.g. `apcore-cli math.add`), these built-in options are available (hidden by default; pass `--help --verbose` to display them):
257
259
 
258
260
  | Option | Description |
259
261
  |--------|-------------|
@@ -261,7 +263,7 @@ When executing a module (e.g. `apcore-cli math.add`), these built-in options are
261
263
  | `--yes` / `-y` | Bypass approval prompts |
262
264
  | `--large-input` | Allow STDIN input larger than 10MB |
263
265
  | `--format` | Output format: `json` or `table` |
264
- | `--sandbox` | Run module in subprocess sandbox |
266
+ | `--sandbox` | Run module in subprocess sandbox *(not yet implemented)* |
265
267
 
266
268
  Schema-generated flags (e.g. `--a`, `--b`) are added automatically from the module's `input_schema`.
267
269
 
@@ -328,7 +330,8 @@ cli:
328
330
  - **Schema validation** -- inputs validated against JSON Schema before execution, with `$ref`/`allOf`/`anyOf`/`oneOf` resolution
329
331
  - **Security** -- API key auth (keyring + AES-256-GCM), append-only audit logging, subprocess sandboxing
330
332
  - **Shell completions** -- `apcore-cli completion bash|zsh|fish` generates completion scripts with dynamic module ID completion
331
- - **Man pages** -- `apcore-cli man <command>` generates roff-formatted man pages
333
+ - **Man pages** -- `apcore-cli man <command>` generates per-command man pages; `--help --man` prints a full-program man page via `configure_man_help()`
334
+ - **Documentation URL** -- `set_docs_url()` sets a base URL; per-command help shows `Docs: {url}/commands/{name}`, man page SEE ALSO links to the full docs site
332
335
  - **Audit logging** -- all executions logged to `~/.apcore-cli/audit.jsonl` with SHA-256 input hashing
333
336
 
334
337
  ## How It Works
@@ -353,6 +356,10 @@ apcore-cli (the adapter)
353
356
  |
354
357
  +-- ConfigResolver 4-tier config precedence
355
358
  +-- LazyModuleGroup Dynamic Click command generation
359
+ +-- set_verbose_help Toggle built-in option visibility
360
+ +-- set_docs_url Set base URL for online docs
361
+ +-- build_program_man_page Full-program roff man page
362
+ +-- configure_man_help Add --help --man support to any CLI
356
363
  +-- schema_parser JSON Schema -> Click options
357
364
  +-- ref_resolver $ref / allOf / anyOf / oneOf
358
365
  +-- approval TTY-aware HITL approval
@@ -204,6 +204,8 @@ apcore-cli [OPTIONS] COMMAND [ARGS]
204
204
  | `--log-level` | `WARNING` | Logging: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
205
205
  | `--version` | | Show version and exit |
206
206
  | `--help` | | Show help and exit |
207
+ | `--verbose` | | Show hidden built-in options in `--help` output |
208
+ | `--man` | | Print man page to stdout (use with `--help`) |
207
209
 
208
210
  ### Built-in Commands
209
211
 
@@ -216,7 +218,7 @@ apcore-cli [OPTIONS] COMMAND [ARGS]
216
218
 
217
219
  ### Module Execution Options
218
220
 
219
- When executing a module (e.g. `apcore-cli math.add`), these built-in options are always available:
221
+ When executing a module (e.g. `apcore-cli math.add`), these built-in options are available (hidden by default; pass `--help --verbose` to display them):
220
222
 
221
223
  | Option | Description |
222
224
  |--------|-------------|
@@ -224,7 +226,7 @@ When executing a module (e.g. `apcore-cli math.add`), these built-in options are
224
226
  | `--yes` / `-y` | Bypass approval prompts |
225
227
  | `--large-input` | Allow STDIN input larger than 10MB |
226
228
  | `--format` | Output format: `json` or `table` |
227
- | `--sandbox` | Run module in subprocess sandbox |
229
+ | `--sandbox` | Run module in subprocess sandbox *(not yet implemented)* |
228
230
 
229
231
  Schema-generated flags (e.g. `--a`, `--b`) are added automatically from the module's `input_schema`.
230
232
 
@@ -291,7 +293,8 @@ cli:
291
293
  - **Schema validation** -- inputs validated against JSON Schema before execution, with `$ref`/`allOf`/`anyOf`/`oneOf` resolution
292
294
  - **Security** -- API key auth (keyring + AES-256-GCM), append-only audit logging, subprocess sandboxing
293
295
  - **Shell completions** -- `apcore-cli completion bash|zsh|fish` generates completion scripts with dynamic module ID completion
294
- - **Man pages** -- `apcore-cli man <command>` generates roff-formatted man pages
296
+ - **Man pages** -- `apcore-cli man <command>` generates per-command man pages; `--help --man` prints a full-program man page via `configure_man_help()`
297
+ - **Documentation URL** -- `set_docs_url()` sets a base URL; per-command help shows `Docs: {url}/commands/{name}`, man page SEE ALSO links to the full docs site
295
298
  - **Audit logging** -- all executions logged to `~/.apcore-cli/audit.jsonl` with SHA-256 input hashing
296
299
 
297
300
  ## How It Works
@@ -316,6 +319,10 @@ apcore-cli (the adapter)
316
319
  |
317
320
  +-- ConfigResolver 4-tier config precedence
318
321
  +-- LazyModuleGroup Dynamic Click command generation
322
+ +-- set_verbose_help Toggle built-in option visibility
323
+ +-- set_docs_url Set base URL for online docs
324
+ +-- build_program_man_page Full-program roff man page
325
+ +-- configure_man_help Add --help --man support to any CLI
319
326
  +-- schema_parser JSON Schema -> Click options
320
327
  +-- ref_resolver $ref / allOf / anyOf / oneOf
321
328
  +-- approval TTY-aware HITL approval
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apcore-cli"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Terminal adapter for apcore — execute AI-Perceivable modules from the command line"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -9,7 +9,7 @@ import sys
9
9
  import click
10
10
 
11
11
  from apcore_cli import __version__
12
- from apcore_cli.cli import GroupedModuleGroup, set_audit_logger
12
+ from apcore_cli.cli import GroupedModuleGroup, set_audit_logger, set_verbose_help
13
13
  from apcore_cli.config import ConfigResolver
14
14
  from apcore_cli.discovery import register_discovery_commands
15
15
  from apcore_cli.security.audit import AuditLogger
@@ -50,6 +50,12 @@ def _extract_binding_path(argv: list[str] | None = None) -> str | None:
50
50
  return _extract_argv_option(argv, "--binding")
51
51
 
52
52
 
53
+ def _has_verbose_flag(argv: list[str] | None = None) -> bool:
54
+ """Check if --verbose is present in argv (pre-parse, before Click)."""
55
+ args = argv if argv is not None else sys.argv[1:]
56
+ return "--verbose" in args
57
+
58
+
53
59
  def create_cli(
54
60
  extensions_dir: str | None = None,
55
61
  prog_name: str | None = None,
@@ -75,6 +81,11 @@ def create_cli(
75
81
  if prog_name is None:
76
82
  prog_name = os.path.basename(sys.argv[0]) or "apcore-cli"
77
83
 
84
+ # Pre-parse --verbose before Click runs so build_module_command knows
85
+ # whether to hide built-in options.
86
+ verbose = _has_verbose_flag()
87
+ set_verbose_help(verbose)
88
+
78
89
  # Resolve CLI log level (3-tier precedence, evaluated before Click runs):
79
90
  # APCORE_CLI_LOGGING_LEVEL (CLI-specific) > APCORE_LOGGING_LEVEL (global) > WARNING
80
91
  # The --log-level flag (parsed later) can further override at runtime.
@@ -115,7 +126,7 @@ def create_cli(
115
126
 
116
127
  if ext_dir_missing:
117
128
  click.echo(
118
- f"Error: Extensions directory not found: '{ext_dir}'. " "Set APCORE_EXTENSIONS_ROOT or verify the path.",
129
+ f"Error: Extensions directory not found: '{ext_dir}'. Set APCORE_EXTENSIONS_ROOT or verify the path.",
119
130
  err=True,
120
131
  )
121
132
  sys.exit(EXIT_CONFIG_NOT_FOUND)
@@ -212,6 +223,13 @@ def create_cli(
212
223
  type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
213
224
  help="Log verbosity. Overrides APCORE_CLI_LOGGING_LEVEL and APCORE_LOGGING_LEVEL env vars.",
214
225
  )
226
+ @click.option(
227
+ "--verbose",
228
+ "verbose_help",
229
+ is_flag=True,
230
+ default=False,
231
+ help="Show all options in help output (including built-in apcore options).",
232
+ )
215
233
  @click.pass_context
216
234
  def cli(
217
235
  ctx: click.Context,
@@ -219,6 +237,7 @@ def create_cli(
219
237
  commands_dir_opt: str | None = None,
220
238
  binding_opt: str | None = None,
221
239
  log_level: str | None = None,
240
+ verbose_help: bool = False,
222
241
  ) -> None:
223
242
  if log_level is not None:
224
243
  # basicConfig() is a no-op once handlers exist; set level on the root logger directly.
@@ -229,6 +248,7 @@ def create_cli(
229
248
  logging.getLogger("apcore").setLevel(apcore_level)
230
249
  ctx.ensure_object(dict)
231
250
  ctx.obj["extensions_dir"] = ext_dir
251
+ ctx.obj["verbose_help"] = verbose_help
232
252
 
233
253
  # Register discovery commands
234
254
  register_discovery_commands(cli, registry)
@@ -32,6 +32,33 @@ BUILTIN_COMMANDS = ["completion", "describe", "exec", "init", "list", "man"]
32
32
  # Module-level audit logger, set during CLI init
33
33
  _audit_logger: AuditLogger | None = None
34
34
 
35
+ # Module-level verbose help flag, set during CLI init
36
+ _verbose_help: bool = False
37
+
38
+
39
+ def set_verbose_help(verbose: bool) -> None:
40
+ """Set the verbose help flag. When False, built-in options are hidden."""
41
+ global _verbose_help
42
+ _verbose_help = verbose
43
+
44
+
45
+ # Module-level docs URL, set by downstream projects
46
+ _docs_url: str | None = None
47
+
48
+
49
+ def set_docs_url(url: str | None) -> None:
50
+ """Set the base URL for online documentation links in help and man pages.
51
+
52
+ Pass None to disable. Command-level help appends ``/commands/{name}``
53
+ automatically.
54
+
55
+ Example::
56
+
57
+ set_docs_url("https://docs.apcore.dev/cli")
58
+ """
59
+ global _docs_url
60
+ _docs_url = url
61
+
35
62
 
36
63
  def set_audit_logger(audit_logger: AuditLogger | None) -> None:
37
64
  """Set the global audit logger instance. Pass None to clear."""
@@ -452,18 +479,27 @@ def build_module_command(
452
479
  sys.exit(exit_code)
453
480
 
454
481
  # Build the command with schema-generated options + built-in options
482
+ _epilog_parts: list[str] = []
483
+ if not _verbose_help:
484
+ _epilog_parts.append("Use --verbose to show all options (including built-in apcore options).")
485
+ if _docs_url:
486
+ _epilog_parts.append(f"Docs: {_docs_url}/commands/{effective_cmd_name}")
487
+ _epilog = "\n".join(_epilog_parts) if _epilog_parts else None
455
488
  cmd = click.Command(
456
489
  name=effective_cmd_name,
457
490
  help=cmd_help,
458
491
  callback=callback,
492
+ epilog=_epilog,
459
493
  )
460
494
 
461
- # Add built-in options
495
+ # Add built-in options (hidden unless --verbose is passed with --help)
496
+ _hide = not _verbose_help
462
497
  cmd.params.append(
463
498
  click.Option(
464
499
  ["--input"],
465
500
  default=None,
466
- help="Read input from file or STDIN ('-').",
501
+ help="Read JSON input from a file path, or use '-' to read from stdin pipe.",
502
+ hidden=_hide,
467
503
  )
468
504
  )
469
505
  cmd.params.append(
@@ -471,7 +507,8 @@ def build_module_command(
471
507
  ["--yes", "-y"],
472
508
  is_flag=True,
473
509
  default=False,
474
- help="Bypass approval prompts.",
510
+ help="Skip interactive approval prompts (for scripts and CI).",
511
+ hidden=_hide,
475
512
  )
476
513
  )
477
514
  cmd.params.append(
@@ -479,7 +516,8 @@ def build_module_command(
479
516
  ["--large-input"],
480
517
  is_flag=True,
481
518
  default=False,
482
- help="Allow STDIN input larger than 10MB.",
519
+ help="Allow stdin input larger than 10MB (default limit protects against accidental pipes).",
520
+ hidden=_hide,
483
521
  )
484
522
  )
485
523
  cmd.params.append(
@@ -487,20 +525,23 @@ def build_module_command(
487
525
  ["--format"],
488
526
  type=click.Choice(["json", "table"]),
489
527
  default=None,
490
- help="Output format.",
528
+ help="Set output format: 'json' for machine-readable, 'table' for human-readable.",
529
+ hidden=_hide,
491
530
  )
492
531
  )
532
+ # --sandbox is always hidden (not yet implemented)
493
533
  cmd.params.append(
494
534
  click.Option(
495
535
  ["--sandbox"],
496
536
  is_flag=True,
497
537
  default=False,
498
- help="Run module in subprocess sandbox.",
538
+ help="Run module in an isolated subprocess with restricted filesystem and env access.",
539
+ hidden=True,
499
540
  )
500
541
  )
501
542
 
502
543
  # Guard: schema property names must not collide with built-in option names.
503
- _reserved = {"input", "yes", "large_input", "format", "sandbox"}
544
+ _reserved = {"input", "yes", "large_input", "format", "sandbox", "verbose"}
504
545
  for opt in schema_options:
505
546
  if opt.name in _reserved:
506
547
  click.echo(
@@ -339,6 +339,187 @@ def _generate_man_page(command_name: str, command: click.Command | None, prog_na
339
339
  return "\n".join(sections)
340
340
 
341
341
 
342
+ def _roff_escape(s: str) -> str:
343
+ """Escape a string for roff output."""
344
+ return s.replace("\\", "\\\\").replace("-", "\\-").replace("'", "\\(aq")
345
+
346
+
347
+ def build_program_man_page(
348
+ cli: click.Group,
349
+ prog_name: str,
350
+ version: str,
351
+ description: str | None = None,
352
+ docs_url: str | None = None,
353
+ ) -> str:
354
+ """Build a complete roff man page for the entire CLI program.
355
+
356
+ Covers all registered commands including downstream business commands
357
+ injected via GroupedModuleGroup.
358
+ """
359
+ today = date.today().isoformat()
360
+ desc = description or cli.help or f"{prog_name} CLI"
361
+ s: list[str] = []
362
+
363
+ s.append(f'.TH "{prog_name.upper()}" "1" "{today}" "{prog_name} {version}" "{prog_name} Manual"')
364
+
365
+ s.append(".SH NAME")
366
+ s.append(f"{prog_name} \\- {_roff_escape(desc)}")
367
+
368
+ s.append(".SH SYNOPSIS")
369
+ s.append(f"\\fB{prog_name}\\fR [\\fIglobal\\-options\\fR] \\fIcommand\\fR [\\fIcommand\\-options\\fR]")
370
+
371
+ s.append(".SH DESCRIPTION")
372
+ s.append(_roff_escape(desc))
373
+
374
+ # Global options
375
+ ctx = click.Context(cli, info_name=prog_name)
376
+ params = cli.get_params(ctx)
377
+ visible_params = [p for p in params if not getattr(p, "hidden", False) and p.name not in ("help", "version", "man")]
378
+ if visible_params:
379
+ s.append(".SH GLOBAL OPTIONS")
380
+ for p in visible_params:
381
+ record = p.get_help_record(ctx)
382
+ if record:
383
+ s.append(".TP")
384
+ s.append(f"\\fB{_roff_escape(record[0])}\\fR")
385
+ s.append(_roff_escape(record[1]))
386
+
387
+ # Commands
388
+ cmd_names = cli.list_commands(ctx)
389
+ if cmd_names:
390
+ s.append(".SH COMMANDS")
391
+ for name in sorted(cmd_names):
392
+ if name == "help":
393
+ continue
394
+ cmd = cli.get_command(ctx, name)
395
+ if cmd is None:
396
+ continue
397
+
398
+ cmd_desc = cmd.get_short_help_str() if cmd else ""
399
+ s.append(".TP")
400
+ s.append(f"\\fB{prog_name} {_roff_escape(name)}\\fR")
401
+ if cmd_desc:
402
+ s.append(_roff_escape(cmd_desc))
403
+
404
+ # Command options
405
+ sub_ctx = click.Context(cmd, info_name=name, parent=ctx)
406
+ sub_params = [
407
+ p for p in cmd.get_params(sub_ctx) if not getattr(p, "hidden", False) and p.name not in ("help",)
408
+ ]
409
+ for p in sub_params:
410
+ record = p.get_help_record(sub_ctx)
411
+ if record:
412
+ s.append(".RS")
413
+ s.append(".TP")
414
+ s.append(f"\\fB{_roff_escape(record[0])}\\fR")
415
+ s.append(_roff_escape(record[1]))
416
+ s.append(".RE")
417
+
418
+ # Nested subcommands (groups)
419
+ if isinstance(cmd, click.Group):
420
+ sub_names = cmd.list_commands(sub_ctx)
421
+ for sub_name in sorted(sub_names):
422
+ if sub_name == "help":
423
+ continue
424
+ sub_cmd = cmd.get_command(sub_ctx, sub_name)
425
+ if sub_cmd is None:
426
+ continue
427
+ sub_desc = sub_cmd.get_short_help_str() if sub_cmd else ""
428
+ s.append(".TP")
429
+ s.append(f"\\fB{prog_name} {_roff_escape(name)} {_roff_escape(sub_name)}\\fR")
430
+ if sub_desc:
431
+ s.append(_roff_escape(sub_desc))
432
+ nested_ctx = click.Context(sub_cmd, info_name=sub_name, parent=sub_ctx)
433
+ nested_params = [
434
+ p
435
+ for p in sub_cmd.get_params(nested_ctx)
436
+ if not getattr(p, "hidden", False) and p.name not in ("help",)
437
+ ]
438
+ for p in nested_params:
439
+ record = p.get_help_record(nested_ctx)
440
+ if record:
441
+ s.append(".RS")
442
+ s.append(".TP")
443
+ s.append(f"\\fB{_roff_escape(record[0])}\\fR")
444
+ s.append(_roff_escape(record[1]))
445
+ s.append(".RE")
446
+
447
+ # Environment
448
+ s.append(".SH ENVIRONMENT")
449
+ s.append(".TP")
450
+ s.append("\\fBAPCORE_EXTENSIONS_ROOT\\fR")
451
+ s.append("Path to the apcore extensions directory.")
452
+ s.append(".TP")
453
+ s.append("\\fBAPCORE_CLI_AUTO_APPROVE\\fR")
454
+ s.append("Set to \\fB1\\fR to bypass approval prompts.")
455
+ s.append(".TP")
456
+ s.append("\\fBAPCORE_CLI_LOGGING_LEVEL\\fR")
457
+ s.append("CLI\\-specific logging verbosity (DEBUG|INFO|WARNING|ERROR).")
458
+
459
+ # Exit codes
460
+ s.append(".SH EXIT CODES")
461
+ codes = [
462
+ ("0", "Success."),
463
+ ("1", "Module execution error."),
464
+ ("2", "Invalid input."),
465
+ ("44", "Module not found."),
466
+ ("45", "Schema validation error."),
467
+ ("46", "Approval denied or timed out."),
468
+ ("47", "Configuration error."),
469
+ ("77", "ACL denied."),
470
+ ("130", "Cancelled by user (SIGINT)."),
471
+ ]
472
+ for code, meaning in codes:
473
+ s.append(f".TP\n\\fB{code}\\fR\n{meaning}")
474
+
475
+ s.append(".SH SEE ALSO")
476
+ s.append(f"\\fB{prog_name} \\-\\-help \\-\\-verbose\\fR for full option list.")
477
+ if docs_url:
478
+ s.append(f".PP\nFull documentation at \\fI{_roff_escape(docs_url)}\\fR")
479
+
480
+ return "\n".join(s)
481
+
482
+
483
+ def configure_man_help(
484
+ cli: click.Group,
485
+ prog_name: str,
486
+ version: str,
487
+ description: str | None = None,
488
+ docs_url: str | None = None,
489
+ ) -> None:
490
+ """Configure --help --man support on a Click CLI group.
491
+
492
+ When --man is passed with --help, outputs a complete roff man page
493
+ covering all registered commands. Downstream projects call this once
494
+ to get man page generation for free.
495
+
496
+ .. note::
497
+ Call this **after** all commands are registered on ``cli``.
498
+ The argv pre-parse triggers immediate man page generation, so
499
+ commands added later will not appear in the output.
500
+
501
+ Usage:
502
+ configure_man_help(cli, "reach", "0.2.0", "ReachForge", "https://reachforge.dev/docs")
503
+ """
504
+ # Add --man as a hidden Click option
505
+ cli.params.append(
506
+ click.Option(
507
+ ["--man"],
508
+ is_flag=True,
509
+ default=False,
510
+ hidden=True,
511
+ help="Output man page in roff format (use with --help).",
512
+ )
513
+ )
514
+
515
+ # Pre-parse: if both --help and --man in argv, generate man page and exit
516
+ args = sys.argv[1:]
517
+ if "--man" in args and ("--help" in args or "-h" in args):
518
+ roff = build_program_man_page(cli, prog_name, version, description, docs_url)
519
+ click.echo(roff)
520
+ sys.exit(0)
521
+
522
+
342
523
  def register_shell_commands(cli: click.Group, prog_name: str = "apcore-cli") -> None:
343
524
  """Register completion and man commands."""
344
525
 
@@ -880,3 +880,56 @@ class TestGroupedE2E:
880
880
  group = self._make_e2e_group()
881
881
  result = CliRunner().invoke(group, ["product", "nonexistent"])
882
882
  assert result.exit_code == 2
883
+
884
+
885
+ class TestVerboseHelp:
886
+ """Tests for --verbose help flag controlling built-in option visibility."""
887
+
888
+ def test_builtin_options_hidden_by_default(self):
889
+ """Built-in options are hidden from help by default."""
890
+ from apcore_cli import cli as cli_mod
891
+
892
+ cli_mod._verbose_help = False
893
+ try:
894
+ module_def = _make_mock_module_def()
895
+ cmd = build_module_command(module_def, _make_mock_executor())
896
+ hidden_names = [p.name for p in cmd.params if getattr(p, "hidden", False)]
897
+ assert "input" in hidden_names
898
+ assert "yes" in hidden_names
899
+ assert "large_input" in hidden_names
900
+ assert "format" in hidden_names
901
+ assert "sandbox" in hidden_names
902
+ finally:
903
+ cli_mod._verbose_help = False
904
+
905
+ def test_builtin_options_shown_when_verbose(self):
906
+ """Built-in options are visible when verbose help is enabled."""
907
+ from apcore_cli import cli as cli_mod
908
+
909
+ cli_mod._verbose_help = True
910
+ try:
911
+ module_def = _make_mock_module_def()
912
+ cmd = build_module_command(module_def, _make_mock_executor())
913
+ hidden_names = [p.name for p in cmd.params if getattr(p, "hidden", False)]
914
+ assert "input" not in hidden_names
915
+ assert "yes" not in hidden_names
916
+ assert "large_input" not in hidden_names
917
+ assert "format" not in hidden_names
918
+ # sandbox is always hidden (not yet implemented)
919
+ assert "sandbox" in hidden_names
920
+ finally:
921
+ cli_mod._verbose_help = False
922
+
923
+ def test_set_verbose_help_function(self):
924
+ """set_verbose_help correctly sets the module-level flag."""
925
+ from apcore_cli import cli as cli_mod
926
+ from apcore_cli.cli import set_verbose_help
927
+
928
+ original = cli_mod._verbose_help
929
+ try:
930
+ set_verbose_help(True)
931
+ assert cli_mod._verbose_help is True
932
+ set_verbose_help(False)
933
+ assert cli_mod._verbose_help is False
934
+ finally:
935
+ cli_mod._verbose_help = original
@@ -7,6 +7,8 @@ from apcore_cli.shell import (
7
7
  _generate_bash_completion,
8
8
  _generate_fish_completion,
9
9
  _generate_zsh_completion,
10
+ build_program_man_page,
11
+ configure_man_help,
10
12
  register_shell_commands,
11
13
  )
12
14
 
@@ -231,3 +233,105 @@ class TestManCommand:
231
233
  assert "SYNOPSIS" in result.output
232
234
  # Should reflect the actual --tag option, not generic [ARGUMENTS]
233
235
  assert "--tag" in result.output
236
+
237
+
238
+ class TestBuildProgramManPage:
239
+ def test_generates_roff_with_th_header(self):
240
+ @click.group()
241
+ def cli():
242
+ pass
243
+
244
+ @cli.command()
245
+ @click.option("--name", help="Your name")
246
+ def hello(name):
247
+ pass
248
+
249
+ roff = build_program_man_page(cli, "test-cli", "1.0.0")
250
+ assert '.TH "TEST-CLI"' in roff
251
+ assert ".SH COMMANDS" in roff
252
+ assert "hello" in roff
253
+
254
+ def test_includes_nested_subcommands(self):
255
+ @click.group()
256
+ def cli():
257
+ pass
258
+
259
+ @cli.group()
260
+ def grp():
261
+ pass
262
+
263
+ @grp.command()
264
+ @click.option("--flag", is_flag=True, help="A flag")
265
+ def sub(flag):
266
+ pass
267
+
268
+ roff = build_program_man_page(cli, "mycli", "1.0.0")
269
+ assert "mycli grp sub" in roff
270
+
271
+ def test_includes_standard_sections(self):
272
+ @click.group()
273
+ def cli():
274
+ """My test CLI."""
275
+
276
+ roff = build_program_man_page(cli, "test-cli", "1.0.0")
277
+ assert ".SH NAME" in roff
278
+ assert ".SH SYNOPSIS" in roff
279
+ assert ".SH DESCRIPTION" in roff
280
+ assert ".SH ENVIRONMENT" in roff
281
+ assert ".SH EXIT CODES" in roff
282
+ assert ".SH SEE ALSO" in roff
283
+
284
+ def test_uses_custom_description(self):
285
+ @click.group()
286
+ def cli():
287
+ pass
288
+
289
+ roff = build_program_man_page(cli, "test-cli", "1.0.0", description="Custom desc")
290
+ assert "Custom desc" in roff
291
+
292
+ def test_includes_command_options(self):
293
+ @click.group()
294
+ def cli():
295
+ pass
296
+
297
+ @cli.command()
298
+ @click.option("--output", "-o", help="Output file")
299
+ def build(output):
300
+ pass
301
+
302
+ roff = build_program_man_page(cli, "test-cli", "1.0.0")
303
+ assert "Output file" in roff
304
+
305
+ def test_skips_help_command(self):
306
+ @click.group()
307
+ def cli():
308
+ pass
309
+
310
+ @cli.command()
311
+ def help():
312
+ pass
313
+
314
+ roff = build_program_man_page(cli, "test-cli", "1.0.0")
315
+ # "help" should not appear in the COMMANDS section as a listed command
316
+ assert "test\\-cli help" not in roff
317
+
318
+
319
+ class TestConfigureManHelp:
320
+ def test_adds_hidden_man_option(self):
321
+ @click.group()
322
+ def cli():
323
+ pass
324
+
325
+ configure_man_help(cli, "test-cli", "1.0.0")
326
+ man_params = [p for p in cli.params if p.name == "man"]
327
+ assert len(man_params) == 1
328
+ assert man_params[0].hidden is True
329
+
330
+ def test_man_option_is_flag(self):
331
+ @click.group()
332
+ def cli():
333
+ pass
334
+
335
+ configure_man_help(cli, "test-cli", "1.0.0")
336
+ man_params = [p for p in cli.params if p.name == "man"]
337
+ assert man_params[0].is_flag is True
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes