apcore-cli 0.3.0__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.0 → apcore_cli-0.4.0}/CHANGELOG.md +34 -0
  2. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/PKG-INFO +13 -6
  3. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/README.md +11 -4
  4. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/pyproject.toml +2 -2
  5. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/__main__.py +51 -5
  6. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/cli.py +49 -8
  7. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/init_cmd.py +5 -0
  8. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/shell.py +189 -2
  9. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_cli.py +53 -0
  10. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_shell.py +104 -0
  11. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/.github/CODEOWNERS +0 -0
  12. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/.github/copilot-ignore +0 -0
  13. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/.github/workflows/ci.yml +0 -0
  14. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/.gitignore +0 -0
  15. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/.gitmessage +0 -0
  16. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/.pre-commit-config.yaml +0 -0
  17. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/CLAUDE.md +0 -0
  18. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/commands/ops.py +0 -0
  19. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/examples/extensions/math/add.py +0 -0
  20. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/examples/extensions/math/multiply.py +0 -0
  21. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/examples/extensions/sysutil/disk.py +0 -0
  22. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/examples/extensions/sysutil/env.py +0 -0
  23. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/examples/extensions/sysutil/info.py +0 -0
  24. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/examples/extensions/text/reverse.py +0 -0
  25. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/examples/extensions/text/upper.py +0 -0
  26. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/examples/extensions/text/wordcount.py +0 -0
  27. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/examples/run_examples.sh +0 -0
  28. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/approval-gate.md +0 -0
  29. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/config-resolver.md +0 -0
  30. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/core-dispatcher.md +0 -0
  31. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/discovery.md +0 -0
  32. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/grouped-commands.md +0 -0
  33. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/output-formatter.md +0 -0
  34. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/overview.md +0 -0
  35. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/schema-parser.md +0 -0
  36. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/security-manager.md +0 -0
  37. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/shell-integration.md +0 -0
  38. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/planning/state.json +0 -0
  39. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/__init__.py +0 -0
  40. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/_sandbox_runner.py +0 -0
  41. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/approval.py +0 -0
  42. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/config.py +0 -0
  43. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/discovery.py +0 -0
  44. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/display_helpers.py +0 -0
  45. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/output.py +0 -0
  46. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/ref_resolver.py +0 -0
  47. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/schema_parser.py +0 -0
  48. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/security/__init__.py +0 -0
  49. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/security/audit.py +0 -0
  50. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/security/auth.py +0 -0
  51. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/security/config_encryptor.py +0 -0
  52. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/src/apcore_cli/security/sandbox.py +0 -0
  53. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/__init__.py +0 -0
  54. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/conftest.py +0 -0
  55. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_approval.py +0 -0
  56. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_bugfixes.py +0 -0
  57. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_config.py +0 -0
  58. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_discovery.py +0 -0
  59. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_e2e.py +0 -0
  60. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_init_cmd.py +0 -0
  61. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_integration.py +0 -0
  62. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_output.py +0 -0
  63. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_ref_resolver.py +0 -0
  64. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_schema_parser.py +0 -0
  65. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_security/__init__.py +0 -0
  66. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_security/test_audit.py +0 -0
  67. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_security/test_auth.py +0 -0
  68. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_security/test_config_encryptor.py +0 -0
  69. {apcore_cli-0.3.0 → apcore_cli-0.4.0}/tests/test_security/test_sandbox.py +0 -0
@@ -5,6 +5,40 @@ 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
+
22
+ ## [0.3.1] - 2026-03-27
23
+
24
+ ### Added
25
+
26
+ - **DisplayResolver integration** — `__main__.py` integrates `DisplayResolver` from `apcore-toolkit` (optional) when `--binding` option is provided; gracefully skipped when not installed.
27
+ - **`init` to `BUILTIN_COMMANDS`** — `init` subcommand is now registered in the builtin commands set.
28
+ - **`APCORE_AUTH_API_KEY` to man page** — environment variable documented in generated roff man page.
29
+ - **Grouped shell completion with `_APCORE_GRP`** — bash/zsh/fish completion scripts now support two-level group/command completion via the `_APCORE_GRP` environment variable (`shell.py`).
30
+ - **Path traversal validation for `--dir` in `init` command** — rejects paths containing `..` segments to prevent directory escape (`init_cmd.py`).
31
+
32
+ ### Fixed
33
+
34
+ - **`RegistryWriter` API call** — constructor now called without parameters; fixes `TypeError` introduced by upstream API change.
35
+
36
+ ### Changed
37
+
38
+ - `apcore` dependency bumped to `>=0.14.0`.
39
+
40
+ ---
41
+
8
42
  ## [0.3.0] - 2026-03-23
9
43
 
10
44
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apcore-cli
3
- Version: 0.3.0
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
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Requires-Python: >=3.11
23
- Requires-Dist: apcore>=0.13.1
23
+ Requires-Dist: apcore>=0.14.0
24
24
  Requires-Dist: click>=8.1
25
25
  Requires-Dist: cryptography>=41.0
26
26
  Requires-Dist: jsonschema>=4.20
@@ -85,7 +85,7 @@ Terminal adapter for apcore. Execute AI-Perceivable modules from the command lin
85
85
  pip install apcore-cli
86
86
  ```
87
87
 
88
- Requires Python 3.11+ and `apcore >= 0.13.0`.
88
+ Requires Python 3.11+ and `apcore >= 0.14.0`.
89
89
 
90
90
  ## Quick Start
91
91
 
@@ -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
@@ -48,7 +48,7 @@ Terminal adapter for apcore. Execute AI-Perceivable modules from the command lin
48
48
  pip install apcore-cli
49
49
  ```
50
50
 
51
- Requires Python 3.11+ and `apcore >= 0.13.0`.
51
+ Requires Python 3.11+ and `apcore >= 0.14.0`.
52
52
 
53
53
  ## Quick Start
54
54
 
@@ -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.0"
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"
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Environment :: Console",
27
27
  ]
28
28
  dependencies = [
29
- "apcore>=0.13.1",
29
+ "apcore>=0.14.0",
30
30
  "click>=8.1",
31
31
  "jsonschema>=4.20",
32
32
  "rich>=13.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
@@ -45,10 +45,22 @@ def _extract_commands_dir(argv: list[str] | None = None) -> str | None:
45
45
  return _extract_argv_option(argv, "--commands-dir")
46
46
 
47
47
 
48
+ def _extract_binding_path(argv: list[str] | None = None) -> str | None:
49
+ """Extract --binding value from argv before Click parses it."""
50
+ return _extract_argv_option(argv, "--binding")
51
+
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
+
48
59
  def create_cli(
49
60
  extensions_dir: str | None = None,
50
61
  prog_name: str | None = None,
51
62
  commands_dir: str | None = None,
63
+ binding_path: str | None = None,
52
64
  ) -> click.Group:
53
65
  """Create the CLI application.
54
66
 
@@ -62,10 +74,18 @@ def create_cli(
62
74
  commands_dir: Directory containing convention-based modules.
63
75
  When set, scans for plain-function modules and registers
64
76
  them via ConventionScanner (requires apcore-toolkit).
77
+ binding_path: Path to binding.yaml file or directory for display resolution.
78
+ When set, applies DisplayResolver to convention-scanned modules
79
+ (requires apcore-toolkit).
65
80
  """
66
81
  if prog_name is None:
67
82
  prog_name = os.path.basename(sys.argv[0]) or "apcore-cli"
68
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
+
69
89
  # Resolve CLI log level (3-tier precedence, evaluated before Click runs):
70
90
  # APCORE_CLI_LOGGING_LEVEL (CLI-specific) > APCORE_LOGGING_LEVEL (global) > WARNING
71
91
  # The --log-level flag (parsed later) can further override at runtime.
@@ -106,7 +126,7 @@ def create_cli(
106
126
 
107
127
  if ext_dir_missing:
108
128
  click.echo(
109
- 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.",
110
130
  err=True,
111
131
  )
112
132
  sys.exit(EXIT_CONFIG_NOT_FOUND)
@@ -138,8 +158,17 @@ def create_cli(
138
158
  conv_scanner = ConventionScanner()
139
159
  conv_modules = conv_scanner.scan(commands_dir)
140
160
  if conv_modules:
141
- writer = RegistryWriter(registry=registry)
142
- writer.write(conv_modules)
161
+ if binding_path is not None:
162
+ try:
163
+ from apcore_toolkit import DisplayResolver
164
+
165
+ display_resolver = DisplayResolver()
166
+ conv_modules = display_resolver.resolve(conv_modules, binding_path=binding_path)
167
+ logger.info("DisplayResolver: applied binding from %s", binding_path)
168
+ except ImportError:
169
+ logger.warning("DisplayResolver not available in apcore-toolkit")
170
+ writer = RegistryWriter()
171
+ writer.write(conv_modules, registry)
143
172
  logger.info("Convention scanner: registered %d modules from %s", len(conv_modules), commands_dir)
144
173
  except ImportError:
145
174
  logger.warning("apcore-toolkit not installed — convention module scanning unavailable")
@@ -182,18 +211,33 @@ def create_cli(
182
211
  default=None,
183
212
  help="Path to convention-based commands directory.",
184
213
  )
214
+ @click.option(
215
+ "--binding",
216
+ "binding_opt",
217
+ default=None,
218
+ help="Path to binding.yaml file or directory for display resolution.",
219
+ )
185
220
  @click.option(
186
221
  "--log-level",
187
222
  default=None,
188
223
  type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
189
224
  help="Log verbosity. Overrides APCORE_CLI_LOGGING_LEVEL and APCORE_LOGGING_LEVEL env vars.",
190
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
+ )
191
233
  @click.pass_context
192
234
  def cli(
193
235
  ctx: click.Context,
194
236
  extensions_dir_opt: str | None = None,
195
237
  commands_dir_opt: str | None = None,
238
+ binding_opt: str | None = None,
196
239
  log_level: str | None = None,
240
+ verbose_help: bool = False,
197
241
  ) -> None:
198
242
  if log_level is not None:
199
243
  # basicConfig() is a no-op once handlers exist; set level on the root logger directly.
@@ -204,6 +248,7 @@ def create_cli(
204
248
  logging.getLogger("apcore").setLevel(apcore_level)
205
249
  ctx.ensure_object(dict)
206
250
  ctx.obj["extensions_dir"] = ext_dir
251
+ ctx.obj["verbose_help"] = verbose_help
207
252
 
208
253
  # Register discovery commands
209
254
  register_discovery_commands(cli, registry)
@@ -228,7 +273,8 @@ def main(prog_name: str | None = None) -> None:
228
273
  """
229
274
  ext_dir = _extract_extensions_dir()
230
275
  cmd_dir = _extract_commands_dir()
231
- cli = create_cli(extensions_dir=ext_dir, prog_name=prog_name, commands_dir=cmd_dir)
276
+ bind_path = _extract_binding_path()
277
+ cli = create_cli(extensions_dir=ext_dir, prog_name=prog_name, commands_dir=cmd_dir, binding_path=bind_path)
232
278
  cli(standalone_mode=True)
233
279
 
234
280
 
@@ -27,11 +27,38 @@ if TYPE_CHECKING:
27
27
 
28
28
  logger = logging.getLogger("apcore_cli.cli")
29
29
 
30
- BUILTIN_COMMANDS = ["exec", "list", "describe", "completion", "man"]
30
+ BUILTIN_COMMANDS = ["completion", "describe", "exec", "init", "list", "man"]
31
31
 
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(
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import sys
5
6
  from pathlib import Path
6
7
 
7
8
  import click
@@ -59,6 +60,10 @@ def register_init_command(cli: click.Group) -> None:
59
60
 
60
61
  MODULE_ID is the module identifier (e.g., ops.deploy, user.create).
61
62
  """
63
+ if output_dir is not None and ".." in Path(output_dir).parts:
64
+ click.echo("Error: Output directory must not contain '..' path components.", err=True)
65
+ sys.exit(2)
66
+
62
67
  # Parse module_id into parts
63
68
  parts = module_id.rsplit(".", 1)
64
69
  if len(parts) == 2:
@@ -59,7 +59,7 @@ def _generate_bash_completion(prog_name: str) -> str:
59
59
  "\n"
60
60
  " if [[ ${COMP_CWORD} -eq 1 ]]; then\n"
61
61
  f" local all_ids=$({groups_and_top_cmd})\n"
62
- ' local builtins="exec list describe completion man"\n'
62
+ ' local builtins="completion describe exec init list man"\n'
63
63
  ' COMPREPLY=( $(compgen -W "${builtins} ${all_ids}" -- ${cur}) )\n'
64
64
  " return 0\n"
65
65
  " fi\n"
@@ -124,6 +124,7 @@ def _generate_zsh_completion(prog_name: str) -> str:
124
124
  " 'list:List available modules'\n"
125
125
  " 'describe:Show module metadata and schema'\n"
126
126
  " 'completion:Generate shell completion script'\n"
127
+ " 'init:Scaffolding commands'\n"
127
128
  " 'man:Generate man page'\n"
128
129
  " )\n"
129
130
  "\n"
@@ -208,6 +209,8 @@ def _generate_fish_completion(prog_name: str) -> str:
208
209
  f'complete -c {quoted} -n "__fish_use_subcommand"'
209
210
  ' -a completion -d "Generate shell completion script"\n'
210
211
  f'complete -c {quoted} -n "__fish_use_subcommand"'
212
+ ' -a init -d "Scaffolding commands"\n'
213
+ f'complete -c {quoted} -n "__fish_use_subcommand"'
211
214
  ' -a man -d "Generate man page"\n'
212
215
  f'complete -c {quoted} -n "__fish_use_subcommand"'
213
216
  f' -a "({groups_and_top_cmd})" -d "Module group or command"\n'
@@ -304,6 +307,9 @@ def _generate_man_page(command_name: str, command: click.Command | None, prog_na
304
307
  "Global apcore logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. "
305
308
  "Used as fallback when \\fBAPCORE_CLI_LOGGING_LEVEL\\fR is not set. Default: WARNING."
306
309
  )
310
+ sections.append(".TP")
311
+ sections.append("\\fBAPCORE_AUTH_API_KEY\\fR")
312
+ sections.append("API key for authenticating with the apcore registry.")
307
313
 
308
314
  sections.append(".SH EXIT CODES")
309
315
  exit_codes = [
@@ -333,6 +339,187 @@ def _generate_man_page(command_name: str, command: click.Command | None, prog_na
333
339
  return "\n".join(sections)
334
340
 
335
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
+
336
523
  def register_shell_commands(cli: click.Group, prog_name: str = "apcore-cli") -> None:
337
524
  """Register completion and man commands."""
338
525
 
@@ -380,7 +567,7 @@ def register_shell_commands(cli: click.Group, prog_name: str = "apcore-cli") ->
380
567
  parent_group = parent.command
381
568
  cmd = parent_group.commands.get(command) if isinstance(parent_group, click.Group) else None
382
569
 
383
- known_builtins = {"list", "describe", "completion", "man"}
570
+ known_builtins = {"completion", "describe", "exec", "init", "list", "man"}
384
571
  if cmd is None and command not in known_builtins:
385
572
  click.echo(f"Error: Unknown command '{command}'.", err=True)
386
573
  sys.exit(2)
@@ -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