apcore-cli 0.5.0__tar.gz → 0.6.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 (73) hide show
  1. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/CHANGELOG.md +37 -0
  2. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/PKG-INFO +20 -5
  3. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/README.md +18 -3
  4. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/pyproject.toml +2 -2
  5. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/__init__.py +8 -0
  6. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/__main__.py +144 -54
  7. apcore_cli-0.6.0/src/apcore_cli/approval.py +235 -0
  8. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/cli.py +383 -15
  9. apcore_cli-0.6.0/src/apcore_cli/discovery.py +221 -0
  10. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/output.py +72 -12
  11. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/shell.py +6 -1
  12. apcore_cli-0.6.0/src/apcore_cli/strategy.py +135 -0
  13. apcore_cli-0.6.0/src/apcore_cli/system_cmd.py +318 -0
  14. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_config.py +1 -1
  15. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_discovery.py +1 -1
  16. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_integration.py +37 -0
  17. apcore_cli-0.5.0/src/apcore_cli/approval.py +0 -169
  18. apcore_cli-0.5.0/src/apcore_cli/discovery.py +0 -102
  19. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.github/CODEOWNERS +0 -0
  20. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.github/copilot-ignore +0 -0
  21. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.github/workflows/ci.yml +0 -0
  22. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.gitignore +0 -0
  23. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.gitmessage +0 -0
  24. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.pre-commit-config.yaml +0 -0
  25. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/CLAUDE.md +0 -0
  26. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/commands/ops.py +0 -0
  27. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/math/add.py +0 -0
  28. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/math/multiply.py +0 -0
  29. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/sysutil/disk.py +0 -0
  30. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/sysutil/env.py +0 -0
  31. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/sysutil/info.py +0 -0
  32. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/text/reverse.py +0 -0
  33. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/text/upper.py +0 -0
  34. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/text/wordcount.py +0 -0
  35. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/run_examples.sh +0 -0
  36. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/approval-gate.md +0 -0
  37. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/config-resolver.md +0 -0
  38. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/core-dispatcher.md +0 -0
  39. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/discovery.md +0 -0
  40. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/grouped-commands.md +0 -0
  41. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/output-formatter.md +0 -0
  42. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/overview.md +0 -0
  43. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/schema-parser.md +0 -0
  44. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/security-manager.md +0 -0
  45. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/shell-integration.md +0 -0
  46. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/state.json +0 -0
  47. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/_sandbox_runner.py +0 -0
  48. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/config.py +0 -0
  49. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/display_helpers.py +0 -0
  50. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/init_cmd.py +0 -0
  51. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/ref_resolver.py +0 -0
  52. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/schema_parser.py +0 -0
  53. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/security/__init__.py +0 -0
  54. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/security/audit.py +0 -0
  55. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/security/auth.py +0 -0
  56. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/security/config_encryptor.py +0 -0
  57. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/security/sandbox.py +0 -0
  58. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/__init__.py +0 -0
  59. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/conftest.py +0 -0
  60. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_approval.py +0 -0
  61. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_bugfixes.py +0 -0
  62. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_cli.py +0 -0
  63. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_e2e.py +0 -0
  64. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_init_cmd.py +0 -0
  65. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_output.py +0 -0
  66. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_ref_resolver.py +0 -0
  67. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_schema_parser.py +0 -0
  68. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_security/__init__.py +0 -0
  69. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_security/test_audit.py +0 -0
  70. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_security/test_auth.py +0 -0
  71. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_security/test_config_encryptor.py +0 -0
  72. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_security/test_sandbox.py +0 -0
  73. {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_shell.py +0 -0
@@ -6,6 +6,43 @@ 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
8
 
9
+ ## [0.6.0] - 2026-04-06
10
+
11
+ ### Changed
12
+
13
+ - **Dependency bump**: requires `apcore >= 0.17.1` (was `>= 0.15.1`). Adds Execution Pipeline Strategy, Config Bus enhancements, Pipeline v2 declarative step metadata, `minimal` strategy preset.
14
+ - **Schema parser**: Required schema properties now correctly enforced at CLI option level (was silently optional).
15
+ - **Approval gate**: Fixed inverted logic in annotation type guard; `check_approval()` now accepts `timeout` parameter.
16
+
17
+ ### Added
18
+
19
+ - **FE-11: Usability Enhancements** — 11 new capabilities:
20
+ - `--dry-run` preflight mode via `Executor.validate()`. Standalone `validate` command.
21
+ - System management commands: `health`, `usage`, `enable`, `disable`, `reload`, `config get`/`config set`. Graceful no-op when system modules unavailable.
22
+ - Enhanced error output: structured JSON with `ai_guidance`, `suggestion`, `retryable`, `user_fixable`, `details`. TTY hides machine-only fields.
23
+ - `--trace` pipeline visualization via `call_with_trace()`.
24
+ - `CliApprovalHandler` class implementing apcore `ApprovalHandler` protocol, wired to `Executor.set_approval_handler()`. `--approval-timeout`, `--approval-token` flags.
25
+ - `--stream` JSONL output via `Executor.stream()`.
26
+ - Enhanced `list` command: `--search`, `--status`, `--annotation`, `--sort`, `--reverse`, `--deprecated`, `--deps`.
27
+ - `--strategy` selection: `standard`, `internal`, `testing`, `performance`, `minimal`. `describe-pipeline` command.
28
+ - Output format extensions: `--format csv|yaml|jsonl`, `--fields` dot-path field selection.
29
+ - Multi-level grouping: `cli.group_depth` config key.
30
+ - Custom command extension: `create_cli(extra_commands=[...])` with collision detection.
31
+ - New error code: `CONFIG_ENV_MAP_CONFLICT`.
32
+ - New config keys: `cli.approval_timeout` (60), `cli.strategy` ("standard"), `cli.group_depth` (1).
33
+ - New environment variables: `APCORE_CLI_APPROVAL_TIMEOUT`, `APCORE_CLI_STRATEGY`, `APCORE_CLI_GROUP_DEPTH`.
34
+ - New files: `system_cmd.py`, `strategy.py`.
35
+
36
+ ---
37
+
38
+ ## [0.5.1] - 2026-04-03
39
+
40
+ ### Added
41
+ - **Pre-populated registry support** — `create_cli()` accepts optional `registry` and `executor` parameters. When a pre-populated `Registry` is provided, filesystem discovery is skipped entirely. This enables frameworks that register modules at runtime (e.g. apflow's bridge) to generate CLI commands from their existing registry without requiring an extensions directory.
42
+ - Passing `registry` alone auto-builds an `Executor`; passing `executor` without `registry` raises `ValueError`.
43
+
44
+ ---
45
+
9
46
  ## [0.4.1] - 2026-03-30
10
47
 
11
48
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apcore-cli
3
- Version: 0.5.0
3
+ Version: 0.6.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.15.1
23
+ Requires-Dist: apcore>=0.17.1
24
24
  Requires-Dist: click>=8.1
25
25
  Requires-Dist: cryptography>=41.0
26
26
  Requires-Dist: jsonschema>=4.20
@@ -129,14 +129,29 @@ All modules are auto-discovered. CLI flags are auto-generated from each module's
129
129
  ### Programmatic approach (Python API)
130
130
 
131
131
  ```python
132
- from apcore import Registry, Executor
133
- from apcore_cli.__main__ import create_cli
132
+ from apcore_cli import create_cli
134
133
 
135
- # Build the CLI from your registry
134
+ # Build the CLI from an extensions directory (auto-discovers modules)
136
135
  cli = create_cli(extensions_dir="./extensions")
137
136
  cli(standalone_mode=True)
138
137
  ```
139
138
 
139
+ #### Pre-populated registry
140
+
141
+ Frameworks that register modules at runtime (e.g. apflow's bridge) can pass a pre-populated `Registry` directly, skipping filesystem discovery entirely:
142
+
143
+ ```python
144
+ from apcore_cli import create_cli
145
+
146
+ # registry is already populated by your framework
147
+ cli = create_cli(registry=registry, prog_name="myapp")
148
+ cli(standalone_mode=True)
149
+
150
+ # Executor is auto-built from the registry if omitted.
151
+ # You can also provide your own:
152
+ cli = create_cli(registry=registry, executor=executor, prog_name="myapp")
153
+ ```
154
+
140
155
  Or use the `LazyModuleGroup` directly with Click:
141
156
 
142
157
  ```python
@@ -92,14 +92,29 @@ All modules are auto-discovered. CLI flags are auto-generated from each module's
92
92
  ### Programmatic approach (Python API)
93
93
 
94
94
  ```python
95
- from apcore import Registry, Executor
96
- from apcore_cli.__main__ import create_cli
95
+ from apcore_cli import create_cli
97
96
 
98
- # Build the CLI from your registry
97
+ # Build the CLI from an extensions directory (auto-discovers modules)
99
98
  cli = create_cli(extensions_dir="./extensions")
100
99
  cli(standalone_mode=True)
101
100
  ```
102
101
 
102
+ #### Pre-populated registry
103
+
104
+ Frameworks that register modules at runtime (e.g. apflow's bridge) can pass a pre-populated `Registry` directly, skipping filesystem discovery entirely:
105
+
106
+ ```python
107
+ from apcore_cli import create_cli
108
+
109
+ # registry is already populated by your framework
110
+ cli = create_cli(registry=registry, prog_name="myapp")
111
+ cli(standalone_mode=True)
112
+
113
+ # Executor is auto-built from the registry if omitted.
114
+ # You can also provide your own:
115
+ cli = create_cli(registry=registry, executor=executor, prog_name="myapp")
116
+ ```
117
+
103
118
  Or use the `LazyModuleGroup` directly with Click:
104
119
 
105
120
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apcore-cli"
7
- version = "0.5.0"
7
+ version = "0.6.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.15.1",
29
+ "apcore>=0.17.1",
30
30
  "click>=8.1",
31
31
  "jsonschema>=4.20",
32
32
  "rich>=13.0",
@@ -21,7 +21,15 @@ try:
21
21
  "auto_approve": False,
22
22
  "help_text_max_length": 1000,
23
23
  "logging_level": "WARNING",
24
+ "approval_timeout": 60,
25
+ "strategy": "standard",
26
+ "group_depth": 1,
24
27
  },
25
28
  )
26
29
  except (ImportError, AttributeError):
27
30
  pass # apcore < 0.15.0 or not installed
31
+
32
+ # Public API re-exports
33
+ from apcore_cli.__main__ import create_cli
34
+
35
+ __all__ = ["__version__", "create_cli"]
@@ -5,16 +5,23 @@ from __future__ import annotations
5
5
  import logging
6
6
  import os
7
7
  import sys
8
+ from importlib.metadata import PackageNotFoundError
9
+ from importlib.metadata import version as _get_version
10
+ from typing import Any
8
11
 
9
12
  import click
10
13
 
11
- from apcore_cli import __version__
12
14
  from apcore_cli.cli import GroupedModuleGroup, set_audit_logger, set_verbose_help
13
15
  from apcore_cli.config import ConfigResolver
14
16
  from apcore_cli.discovery import register_discovery_commands
15
17
  from apcore_cli.security.audit import AuditLogger
16
18
  from apcore_cli.shell import configure_man_help, register_shell_commands
17
19
 
20
+ try:
21
+ __version__ = _get_version("apcore-cli")
22
+ except PackageNotFoundError:
23
+ __version__ = "unknown"
24
+
18
25
  logger = logging.getLogger("apcore_cli")
19
26
 
20
27
  EXIT_CONFIG_NOT_FOUND = 47
@@ -61,6 +68,9 @@ def create_cli(
61
68
  prog_name: str | None = None,
62
69
  commands_dir: str | None = None,
63
70
  binding_path: str | None = None,
71
+ registry: Any | None = None,
72
+ executor: Any | None = None,
73
+ extra_commands: list[Any] | None = None,
64
74
  ) -> click.Group:
65
75
  """Create the CLI application.
66
76
 
@@ -77,6 +87,12 @@ def create_cli(
77
87
  binding_path: Path to binding.yaml file or directory for display resolution.
78
88
  When set, applies DisplayResolver to convention-scanned modules
79
89
  (requires apcore-toolkit).
90
+ registry: Pre-populated apcore Registry instance. When provided, skips
91
+ filesystem discovery entirely. Useful for frameworks that register
92
+ modules at runtime (e.g. apflow's bridge).
93
+ executor: Pre-built apcore Executor instance. When provided alongside
94
+ registry, skips Executor construction. If omitted but registry
95
+ is provided, an Executor is built from the given registry.
80
96
  """
81
97
  if prog_name is None:
82
98
  prog_name = os.path.basename(sys.argv[0]) or "apcore-cli"
@@ -121,64 +137,91 @@ def create_cli(
121
137
  except (TypeError, ValueError):
122
138
  help_text_max_length = 1000
123
139
 
124
- ext_dir_missing = not os.path.exists(ext_dir)
125
- ext_dir_unreadable = not ext_dir_missing and not os.access(ext_dir, os.R_OK)
126
-
127
- if ext_dir_missing:
128
- click.echo(
129
- f"Error: Extensions directory not found: '{ext_dir}'. Set APCORE_EXTENSIONS_ROOT or verify the path.",
130
- err=True,
131
- )
132
- sys.exit(EXIT_CONFIG_NOT_FOUND)
140
+ if executor is not None and registry is None:
141
+ raise ValueError("executor requires registry pass both or neither")
133
142
 
134
- if ext_dir_unreadable:
135
- click.echo(
136
- f"Error: Cannot read extensions directory: '{ext_dir}'. Check permissions.",
137
- err=True,
138
- )
139
- sys.exit(EXIT_CONFIG_NOT_FOUND)
143
+ if registry is not None:
144
+ # Pre-populated registry provided — skip filesystem discovery.
145
+ try:
146
+ from apcore import Executor as _Executor
140
147
 
141
- try:
142
- from apcore import Executor, Registry
148
+ if executor is None:
149
+ executor = _Executor(registry)
150
+ logger.info("Using pre-populated registry (%d modules).", len(list(registry.list())))
151
+ except Exception as e:
152
+ click.echo(
153
+ f"Error: Failed to initialize executor from provided registry: {e}",
154
+ err=True,
155
+ )
156
+ sys.exit(EXIT_CONFIG_NOT_FOUND)
157
+ else:
158
+ # Standard path: discover modules from filesystem.
159
+ ext_dir_missing = not os.path.exists(ext_dir)
160
+ ext_dir_unreadable = not ext_dir_missing and not os.access(ext_dir, os.R_OK)
161
+
162
+ if ext_dir_missing:
163
+ click.echo(
164
+ f"Error: Extensions directory not found: '{ext_dir}'. Set APCORE_EXTENSIONS_ROOT or verify the path.",
165
+ err=True,
166
+ )
167
+ sys.exit(EXIT_CONFIG_NOT_FOUND)
168
+
169
+ if ext_dir_unreadable:
170
+ click.echo(
171
+ f"Error: Cannot read extensions directory: '{ext_dir}'. Check permissions.",
172
+ err=True,
173
+ )
174
+ sys.exit(EXIT_CONFIG_NOT_FOUND)
143
175
 
144
- registry = Registry(extensions_dir=ext_dir)
145
176
  try:
146
- logger.debug("Loading extensions from %s", ext_dir)
147
- count = registry.discover()
148
- logger.info("Initialized apcore-cli with %d modules.", count)
149
- except Exception as e:
150
- logger.warning("Discovery failed: %s", e)
177
+ from apcore import Executor as _Executor
178
+ from apcore import Registry as _Registry
151
179
 
152
- # Convention module discovery
153
- if commands_dir is not None:
180
+ registry = _Registry(extensions_dir=ext_dir)
154
181
  try:
155
- from apcore_toolkit import RegistryWriter
156
- from apcore_toolkit.convention_scanner import ConventionScanner
157
-
158
- conv_scanner = ConventionScanner()
159
- conv_modules = conv_scanner.scan(commands_dir)
160
- if 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)
172
- logger.info("Convention scanner: registered %d modules from %s", len(conv_modules), commands_dir)
173
- except ImportError:
174
- logger.warning("apcore-toolkit not installed — convention module scanning unavailable")
182
+ logger.debug("Loading extensions from %s", ext_dir)
183
+ count = registry.discover()
184
+ logger.info("Initialized apcore-cli with %d modules.", count)
175
185
  except Exception as e:
176
- logger.warning("Convention module scanning failed: %s", e)
177
-
178
- executor = Executor(registry)
179
- except Exception as e:
180
- click.echo(f"Error: Failed to initialize registry: {e}", err=True)
181
- sys.exit(EXIT_CONFIG_NOT_FOUND)
186
+ logger.warning("Discovery failed: %s", e)
187
+
188
+ # Convention module discovery
189
+ if commands_dir is not None:
190
+ try:
191
+ from apcore_toolkit import RegistryWriter
192
+ from apcore_toolkit.convention_scanner import ConventionScanner
193
+
194
+ conv_scanner = ConventionScanner()
195
+ conv_modules = conv_scanner.scan(commands_dir)
196
+ if conv_modules:
197
+ if binding_path is not None:
198
+ try:
199
+ from apcore_toolkit import DisplayResolver
200
+
201
+ display_resolver = DisplayResolver()
202
+ conv_modules = display_resolver.resolve(conv_modules, binding_path=binding_path)
203
+ logger.info(
204
+ "DisplayResolver: applied binding from %s",
205
+ binding_path,
206
+ )
207
+ except ImportError:
208
+ logger.warning("DisplayResolver not available in apcore-toolkit")
209
+ writer = RegistryWriter()
210
+ writer.write(conv_modules, registry)
211
+ logger.info(
212
+ "Convention scanner: registered %d modules from %s",
213
+ len(conv_modules),
214
+ commands_dir,
215
+ )
216
+ except ImportError:
217
+ logger.warning("apcore-toolkit not installed — convention module scanning unavailable")
218
+ except Exception as e:
219
+ logger.warning("Convention module scanning failed: %s", e)
220
+
221
+ executor = _Executor(registry)
222
+ except Exception as e:
223
+ click.echo(f"Error: Failed to initialize registry: {e}", err=True)
224
+ sys.exit(EXIT_CONFIG_NOT_FOUND)
182
225
 
183
226
  # Initialize audit logger
184
227
  try:
@@ -187,6 +230,22 @@ def create_cli(
187
230
  except Exception as e:
188
231
  logger.warning("Failed to initialize audit logger: %s", e)
189
232
 
233
+ # Wire CliApprovalHandler to Executor (FE-11 §3.5)
234
+ try:
235
+ import contextlib
236
+
237
+ from apcore_cli.approval import CliApprovalHandler
238
+
239
+ approval_timeout = 60
240
+ with contextlib.suppress(TypeError, ValueError):
241
+ approval_timeout = int(config.resolve("cli.approval_timeout", env_var="APCORE_CLI_APPROVAL_TIMEOUT") or 60)
242
+ handler = CliApprovalHandler(auto_approve=False, timeout=approval_timeout)
243
+ if hasattr(executor, "set_approval_handler"):
244
+ executor.set_approval_handler(handler)
245
+ logger.debug("CliApprovalHandler wired to Executor (timeout=%ds).", approval_timeout)
246
+ except Exception as e:
247
+ logger.debug("Could not wire CliApprovalHandler: %s", e)
248
+
190
249
  @click.group(
191
250
  cls=GroupedModuleGroup,
192
251
  registry=registry,
@@ -250,9 +309,24 @@ def create_cli(
250
309
  ctx.obj["extensions_dir"] = ext_dir
251
310
  ctx.obj["verbose_help"] = verbose_help
252
311
 
253
- # Register discovery commands
312
+ # Register discovery commands (list, describe)
254
313
  register_discovery_commands(cli, registry)
255
314
 
315
+ # Register validate command (FE-11 §3.1)
316
+ from apcore_cli.discovery import register_validate_command
317
+
318
+ register_validate_command(cli, registry, executor)
319
+
320
+ # Register system management commands (FE-11 §3.2) — no-op if system modules unavailable
321
+ from apcore_cli.system_cmd import register_system_commands
322
+
323
+ register_system_commands(cli, executor)
324
+
325
+ # Register pipeline introspection command (FE-11 §3.8)
326
+ from apcore_cli.strategy import register_pipeline_command
327
+
328
+ register_pipeline_command(cli, executor)
329
+
256
330
  # Register shell integration commands
257
331
  register_shell_commands(cli, prog_name=prog_name)
258
332
 
@@ -264,6 +338,17 @@ def create_cli(
264
338
 
265
339
  register_init_command(cli)
266
340
 
341
+ # Register extra commands from downstream projects (FE-11 §3.11)
342
+ if extra_commands:
343
+ from apcore_cli.cli import BUILTIN_COMMANDS
344
+
345
+ for cmd in extra_commands:
346
+ cmd_name = getattr(cmd, "name", None)
347
+ if cmd_name and cmd_name in BUILTIN_COMMANDS:
348
+ msg = f"Extra command '{cmd_name}' conflicts with built-in command."
349
+ raise ValueError(msg)
350
+ cli.add_command(cmd)
351
+
267
352
  return cli
268
353
 
269
354
 
@@ -277,7 +362,12 @@ def main(prog_name: str | None = None) -> None:
277
362
  ext_dir = _extract_extensions_dir()
278
363
  cmd_dir = _extract_commands_dir()
279
364
  bind_path = _extract_binding_path()
280
- cli = create_cli(extensions_dir=ext_dir, prog_name=prog_name, commands_dir=cmd_dir, binding_path=bind_path)
365
+ cli = create_cli(
366
+ extensions_dir=ext_dir,
367
+ prog_name=prog_name,
368
+ commands_dir=cmd_dir,
369
+ binding_path=bind_path,
370
+ )
281
371
  cli(standalone_mode=True)
282
372
 
283
373
 
@@ -0,0 +1,235 @@
1
+ """Approval Gate — TTY-aware HITL approval (FE-03, FE-11 §3.5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+ import threading
9
+ from typing import Any
10
+
11
+ import click
12
+
13
+ logger = logging.getLogger("apcore_cli.approval")
14
+
15
+
16
+ class ApprovalTimeoutError(Exception):
17
+ """Raised when the approval prompt times out."""
18
+
19
+ pass
20
+
21
+
22
+ def _get_annotation(annotations: Any, key: str, default: Any = None) -> Any:
23
+ """Get an annotation value from either a dict or a ModuleAnnotations object."""
24
+ if isinstance(annotations, dict):
25
+ return annotations.get(key, default)
26
+ return getattr(annotations, key, default)
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # CliApprovalHandler — implements apcore ApprovalHandler protocol (FE-11 §3.5)
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ class CliApprovalHandler:
35
+ """ApprovalHandler that prompts in TTY, auto-denies in non-TTY (unless bypassed).
36
+
37
+ Implements the apcore ApprovalHandler protocol:
38
+ - ``request_approval(request) -> ApprovalResult``
39
+ - ``check_approval(approval_id) -> ApprovalResult``
40
+
41
+ Pass to Executor via ``executor.set_approval_handler(handler)``.
42
+ """
43
+
44
+ def __init__(self, auto_approve: bool = False, timeout: int = 60) -> None:
45
+ self.auto_approve = auto_approve
46
+ self.timeout = max(1, min(timeout, 3600))
47
+
48
+ async def request_approval(self, request: Any) -> Any:
49
+ """Request approval for a module invocation.
50
+
51
+ Follows the apcore ApprovalRequest/ApprovalResult protocol.
52
+ Returns a dict with ``status``, ``approved_by``, ``reason`` fields
53
+ (duck-type compatible with ApprovalResult dataclass).
54
+ """
55
+ module_id = getattr(request, "module_id", "unknown")
56
+
57
+ # Bypass: auto_approve flag
58
+ if self.auto_approve:
59
+ logger.info("Approval bypassed via --yes flag for module '%s'.", module_id)
60
+ return {"status": "approved", "approved_by": "auto_approve"}
61
+
62
+ # Bypass: APCORE_CLI_AUTO_APPROVE env var
63
+ env_val = os.environ.get("APCORE_CLI_AUTO_APPROVE", "")
64
+ if env_val == "1":
65
+ logger.info("Approval bypassed via APCORE_CLI_AUTO_APPROVE for '%s'.", module_id)
66
+ return {"status": "approved", "approved_by": "env_auto_approve"}
67
+ if env_val != "" and env_val != "1":
68
+ logger.warning("APCORE_CLI_AUTO_APPROVE='%s', expected '1'. Ignoring.", env_val)
69
+
70
+ # Non-TTY: reject
71
+ if not sys.stdin.isatty():
72
+ return {
73
+ "status": "rejected",
74
+ "reason": "Non-interactive session without --yes",
75
+ }
76
+
77
+ # TTY prompt
78
+ annotations = getattr(request, "annotations", None) or {}
79
+ extra = getattr(annotations, "extra", {}) if not isinstance(annotations, dict) else annotations
80
+ message = extra.get("approval_message") or f"Module '{module_id}' requires approval to execute."
81
+
82
+ click.echo(message, err=True)
83
+ try:
84
+ approved = _tty_prompt(module_id, self.timeout)
85
+ except ApprovalTimeoutError:
86
+ return {"status": "timeout", "reason": f"Timed out after {self.timeout}s"}
87
+
88
+ if approved:
89
+ return {"status": "approved", "approved_by": "tty_user"}
90
+ return {"status": "rejected", "reason": "User rejected"}
91
+
92
+ async def check_approval(self, approval_id: str) -> Any:
93
+ """Check status of a previously pending approval (Phase B).
94
+
95
+ CLI does not support async approval polling; always returns rejected.
96
+ """
97
+ return {
98
+ "status": "rejected",
99
+ "reason": "CLI does not support async approval polling",
100
+ }
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Legacy check_approval() — backward-compatible wrapper
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def check_approval(module_def: Any, auto_approve: bool, timeout: int = 60) -> None:
109
+ """Check if module requires approval and handle accordingly.
110
+
111
+ Returns None if approved (or approval not required).
112
+ Calls sys.exit(46) if denied/timed out/pending.
113
+
114
+ Args:
115
+ module_def: Module descriptor with annotations.
116
+ auto_approve: If True, bypass approval (--yes flag).
117
+ timeout: Approval prompt timeout in seconds.
118
+ """
119
+ annotations = getattr(module_def, "annotations", None)
120
+ if annotations is None or (not isinstance(annotations, dict) and not hasattr(annotations, "requires_approval")):
121
+ return
122
+
123
+ requires = _get_annotation(annotations, "requires_approval", False)
124
+ if requires is not True:
125
+ return
126
+
127
+ module_id = getattr(module_def, "module_id", getattr(module_def, "canonical_id", "unknown"))
128
+
129
+ # Bypass: --yes flag (highest priority)
130
+ if auto_approve is True:
131
+ logger.info("Approval bypassed via --yes flag for module '%s'.", module_id)
132
+ return
133
+
134
+ # Bypass: APCORE_CLI_AUTO_APPROVE env var
135
+ env_val = os.environ.get("APCORE_CLI_AUTO_APPROVE", "")
136
+ if env_val == "1":
137
+ logger.info("Approval bypassed via APCORE_CLI_AUTO_APPROVE for '%s'.", module_id)
138
+ return
139
+ if env_val != "" and env_val != "1":
140
+ logger.warning("APCORE_CLI_AUTO_APPROVE='%s', expected '1'. Ignoring.", env_val)
141
+
142
+ # Non-TTY check
143
+ if not sys.stdin.isatty():
144
+ click.echo(
145
+ f"Error: Module '{module_id}' requires approval but no interactive "
146
+ "terminal is available. Use --yes or set APCORE_CLI_AUTO_APPROVE=1 "
147
+ "to bypass.",
148
+ err=True,
149
+ )
150
+ sys.exit(46)
151
+
152
+ # TTY prompt
153
+ _prompt_with_timeout(module_def, timeout=timeout)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Internal prompt implementation
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ def _prompt_with_timeout(module_def: Any, timeout: int = 60) -> None:
162
+ """Display approval prompt with timeout."""
163
+ timeout = max(1, min(timeout, 3600))
164
+
165
+ module_id = getattr(module_def, "module_id", getattr(module_def, "canonical_id", "unknown"))
166
+ annotations = getattr(module_def, "annotations", None) or {}
167
+ message = _get_annotation(annotations, "approval_message", None)
168
+ if message is None:
169
+ message = f"Module '{module_id}' requires approval to execute."
170
+
171
+ click.echo(message, err=True)
172
+
173
+ try:
174
+ approved = _tty_prompt(module_id, timeout)
175
+ except ApprovalTimeoutError:
176
+ click.echo(f"Error: Approval prompt timed out after {timeout} seconds.", err=True)
177
+ sys.exit(46)
178
+
179
+ if approved:
180
+ logger.info("User approved execution of module '%s'.", module_id)
181
+ else:
182
+ click.echo("Error: Approval denied.", err=True)
183
+ sys.exit(46)
184
+
185
+
186
+ def _tty_prompt(module_id: str, timeout: int) -> bool:
187
+ """Run the TTY prompt with timeout. Returns True if approved, raises on timeout."""
188
+ if sys.platform != "win32":
189
+ return _prompt_unix(module_id, timeout)
190
+ return _prompt_windows(module_id, timeout)
191
+
192
+
193
+ def _prompt_unix(module_id: str, timeout: int) -> bool:
194
+ """Unix approval prompt using SIGALRM."""
195
+ import signal
196
+
197
+ def _timeout_handler(signum: int, frame: Any) -> None:
198
+ raise ApprovalTimeoutError()
199
+
200
+ old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
201
+ signal.alarm(timeout)
202
+
203
+ try:
204
+ approved = click.confirm("Proceed?", default=False)
205
+ except ApprovalTimeoutError:
206
+ logger.warning("Approval timed out after %ds for module '%s'.", timeout, module_id)
207
+ raise
208
+ finally:
209
+ signal.alarm(0)
210
+ signal.signal(signal.SIGALRM, old_handler)
211
+
212
+ return approved
213
+
214
+
215
+ def _prompt_windows(module_id: str, timeout: int) -> bool:
216
+ """Windows approval prompt using threading.Timer + ctypes."""
217
+ import ctypes
218
+
219
+ def _interrupt_main() -> None:
220
+ ctypes.pythonapi.PyThreadState_SetAsyncExc(
221
+ ctypes.c_ulong(threading.main_thread().ident),
222
+ ctypes.py_object(ApprovalTimeoutError),
223
+ )
224
+
225
+ timer = threading.Timer(timeout, _interrupt_main)
226
+ timer.start()
227
+
228
+ try:
229
+ approved = click.confirm("Proceed?", default=False)
230
+ timer.cancel()
231
+ return approved
232
+ except ApprovalTimeoutError:
233
+ timer.cancel()
234
+ logger.warning("Approval timed out after %ds for module '%s'.", timeout, module_id)
235
+ raise