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.
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/CHANGELOG.md +37 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/PKG-INFO +20 -5
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/README.md +18 -3
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/pyproject.toml +2 -2
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/__init__.py +8 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/__main__.py +144 -54
- apcore_cli-0.6.0/src/apcore_cli/approval.py +235 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/cli.py +383 -15
- apcore_cli-0.6.0/src/apcore_cli/discovery.py +221 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/output.py +72 -12
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/shell.py +6 -1
- apcore_cli-0.6.0/src/apcore_cli/strategy.py +135 -0
- apcore_cli-0.6.0/src/apcore_cli/system_cmd.py +318 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_config.py +1 -1
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_discovery.py +1 -1
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_integration.py +37 -0
- apcore_cli-0.5.0/src/apcore_cli/approval.py +0 -169
- apcore_cli-0.5.0/src/apcore_cli/discovery.py +0 -102
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.github/CODEOWNERS +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.github/copilot-ignore +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.github/workflows/ci.yml +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.gitignore +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.gitmessage +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/.pre-commit-config.yaml +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/CLAUDE.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/commands/ops.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/math/add.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/math/multiply.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/sysutil/disk.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/sysutil/env.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/sysutil/info.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/text/reverse.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/text/upper.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/extensions/text/wordcount.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/examples/run_examples.sh +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/approval-gate.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/config-resolver.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/core-dispatcher.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/discovery.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/grouped-commands.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/output-formatter.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/overview.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/schema-parser.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/security-manager.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/shell-integration.md +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/planning/state.json +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/_sandbox_runner.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/config.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/display_helpers.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/init_cmd.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/ref_resolver.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/schema_parser.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/security/__init__.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/security/audit.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/security/auth.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/security/config_encryptor.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/src/apcore_cli/security/sandbox.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/__init__.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/conftest.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_approval.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_bugfixes.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_cli.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_e2e.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_init_cmd.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_output.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_ref_resolver.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_schema_parser.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_security/__init__.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_security/test_audit.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_security/test_auth.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_security/test_config_encryptor.py +0 -0
- {apcore_cli-0.5.0 → apcore_cli-0.6.0}/tests/test_security/test_sandbox.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
133
|
-
from apcore_cli.__main__ import create_cli
|
|
132
|
+
from apcore_cli import create_cli
|
|
134
133
|
|
|
135
|
-
# Build the CLI from
|
|
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
|
|
96
|
-
from apcore_cli.__main__ import create_cli
|
|
95
|
+
from apcore_cli import create_cli
|
|
97
96
|
|
|
98
|
-
# Build the CLI from
|
|
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.
|
|
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.
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
153
|
-
if commands_dir is not None:
|
|
180
|
+
registry = _Registry(extensions_dir=ext_dir)
|
|
154
181
|
try:
|
|
155
|
-
from
|
|
156
|
-
|
|
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("
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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(
|
|
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
|