apcore-cli 0.1.0__tar.gz → 0.2.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.2.0/CHANGELOG.md +73 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/PKG-INFO +7 -6
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/README.md +6 -5
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/math/add.py +3 -3
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/math/multiply.py +3 -3
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/sysutil/disk.py +2 -2
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/sysutil/env.py +3 -3
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/text/reverse.py +2 -2
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/text/upper.py +2 -2
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/text/wordcount.py +2 -2
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/pyproject.toml +1 -1
- apcore_cli-0.2.0/src/apcore_cli/__init__.py +9 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/__main__.py +43 -10
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/approval.py +14 -12
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/cli.py +17 -5
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/discovery.py +2 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/output.py +16 -2
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/schema_parser.py +8 -4
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/security/audit.py +16 -2
- apcore_cli-0.2.0/src/apcore_cli/shell.py +290 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_approval.py +15 -30
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_cli.py +74 -2
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_e2e.py +3 -1
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_integration.py +58 -33
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_schema_parser.py +5 -1
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_security/test_audit.py +27 -8
- apcore_cli-0.2.0/tests/test_shell.py +186 -0
- apcore_cli-0.1.0/CHANGELOG.md +0 -37
- apcore_cli-0.1.0/src/apcore_cli/__init__.py +0 -3
- apcore_cli-0.1.0/src/apcore_cli/shell.py +0 -185
- apcore_cli-0.1.0/tests/test_shell.py +0 -126
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.github/CODEOWNERS +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.github/copilot-ignore +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.gitignore +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.gitmessage +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.pre-commit-config.yaml +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/sysutil/info.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/run_examples.sh +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/approval-gate.md +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/config-resolver.md +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/core-dispatcher.md +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/discovery.md +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/output-formatter.md +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/overview.md +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/schema-parser.md +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/security-manager.md +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/shell-integration.md +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/state.json +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/_sandbox_runner.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/config.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/ref_resolver.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/security/__init__.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/security/auth.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/security/config_encryptor.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/security/sandbox.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/__init__.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/conftest.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_bugfixes.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_config.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_discovery.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_output.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_ref_resolver.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_security/__init__.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_security/test_auth.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_security/test_config_encryptor.py +0 -0
- {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_security/test_sandbox.py +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to apcore-cli (Python SDK) will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - 2026-03-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `APCORE_CLI_LOGGING_LEVEL` env var — CLI-specific log level that takes priority over `APCORE_LOGGING_LEVEL`; 3-tier precedence: `--log-level` flag > `APCORE_CLI_LOGGING_LEVEL` > `APCORE_LOGGING_LEVEL` > `WARNING` (`__main__.py`)
|
|
12
|
+
- `test_cli_logging_level_takes_priority_over_global` — verifies `APCORE_CLI_LOGGING_LEVEL=DEBUG` wins over `APCORE_LOGGING_LEVEL=ERROR`
|
|
13
|
+
- `test_cli_logging_level_fallback_to_global` — verifies fallback when CLI-specific var is unset
|
|
14
|
+
- `test_builtin_name_collision_exits_2` — schema property named `format` (or other reserved names) causes `build_module_command` to exit 2
|
|
15
|
+
- `test_exec_result_table_format` — `--format table` renders Rich Key/Value table to stdout
|
|
16
|
+
- `test_bash_completion_quotes_prog_name_in_directive` — verifies `shlex.quote()` applied to `complete -F` directive, not just embedded subshell
|
|
17
|
+
- `test_zsh_completion_quotes_prog_name_in_directives` — verifies `compdef` line uses quoted prog_name
|
|
18
|
+
- `test_fish_completion_quotes_prog_name_in_directives` — verifies `complete -c` lines use quoted prog_name
|
|
19
|
+
- 17 new tests (244 → 261 total)
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- `--log-level` accepted choices: `WARN` → `WARNING` (`__main__.py`)
|
|
23
|
+
- `schema_to_click_options`: schema-derived options now always have `required=False`; required fields marked `[required]` in help text instead of Click enforcement — allows `--input -` STDIN to supply required values without Click rejecting first (`schema_parser.py`)
|
|
24
|
+
- `format_exec_result`: now routes through `resolve_format()` and renders Rich table when `--format table` is specified; previously ignored its `format` parameter (`output.py`)
|
|
25
|
+
- `_generate_bash_completion`, `_generate_zsh_completion`, `_generate_fish_completion`: `shlex.quote()` applied to ALL prog_name positions in generated scripts (complete directives, compdef, complete -c), not only embedded subshell commands (`shell.py`)
|
|
26
|
+
- `check_approval`: removed unused `ctx: click.Context` parameter (`approval.py`)
|
|
27
|
+
- `set_audit_logger`: broadened type annotation from `AuditLogger` to `AuditLogger | None` (`cli.py`)
|
|
28
|
+
- `collect_input`: simplified redundant condition `if not raw or raw_size == 0:` → `if not raw:` (`cli.py`)
|
|
29
|
+
- Example `Input` models: all 7 modules updated with `Field(description=...)` on every field so CLI `--help` shows descriptive text for each flag
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **`--input -` STDIN blocked by Click required enforcement**: `schema_to_click_options` was generating `required=True` Click options; Click validated before the callback ran, rejecting STDIN-only invocations. Resolved by always using `required=False` and delegating required validation to `jsonschema.validate()` after input collection. Fixes all 6 `TestRealStdinPiping` failures.
|
|
33
|
+
- **`--log-level` had no effect**: `logging.basicConfig()` is a no-op after the first call; subsequent `create_cli()` calls in tests retained the prior handler's level. Fixed by calling `logging.getLogger().setLevel()` explicitly after `basicConfig()`.
|
|
34
|
+
- **`test_log_level_flag_takes_effect` false pass**: `--help` is an eager flag that exits before the group callback, so `--log-level DEBUG --help` never applied the log level. Test updated to use `completion bash` subcommand instead.
|
|
35
|
+
- **Shell completion directives not shell-safe**: prog names with spaces or special characters were unquoted in `complete -F`, `compdef`, and `complete -c` lines. Fixed by assigning `quoted = shlex.quote(prog_name)` and using it in all directive positions.
|
|
36
|
+
- **Audit `set_audit_logger(None)` type error**: type annotation rejected `None`; broadened to `AuditLogger | None`.
|
|
37
|
+
- **Test logger level leakage**: tests modifying root logger level affected subsequent tests; fixed with `try/finally` that restores the original level.
|
|
38
|
+
|
|
39
|
+
### Security
|
|
40
|
+
- `AuditLogger._hash_input`: now uses `secrets.token_bytes(16)` per-invocation salt before hashing, preventing cross-invocation input correlation via SHA-256 rainbow tables
|
|
41
|
+
- `build_module_command`: added reserved-name collision guard — exits 2 if a schema property (`input`, `yes`, `large_input`, `format`, `sandbox`) conflicts with a built-in CLI option name
|
|
42
|
+
- `_prompt_with_timeout` (SIGALRM path): wrapped in `try/finally` to guarantee signal handler restoration regardless of exit path
|
|
43
|
+
|
|
44
|
+
## [0.1.0] - 2026-03-15
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- `--sandbox` flag for subprocess-isolated module execution (FE-05)
|
|
48
|
+
- `ModuleExecutionError` exception class for sandbox failures
|
|
49
|
+
- Windows approval timeout support via `threading.Timer` + `ctypes` (FE-03)
|
|
50
|
+
- Approval timeout clamping to 1..3600 seconds range (FE-03)
|
|
51
|
+
- Tag format validation (`^[a-z][a-z0-9_-]*$`) in `list --tag` (FE-04)
|
|
52
|
+
- `cli.auto_approve` config key with `False` default (FE-07)
|
|
53
|
+
- Extensions directory readability check with exit code 47 (FE-01)
|
|
54
|
+
- Missing required property warning in schema parser (FE-02)
|
|
55
|
+
- DEBUG log `"Loading extensions from {path}"` before registry discovery (FE-01)
|
|
56
|
+
- `TYPE_CHECKING` imports for proper type annotations (`Registry`, `Executor`, `ModuleDescriptor`, `ConfigResolver`, `AuditLogger`)
|
|
57
|
+
- `_get_module_id()` helper for `canonical_id`/`module_id` resolution
|
|
58
|
+
- `APCORE_AUTH_API_KEY` and `APCORE_CLI_SANDBOX` to README environment variables table
|
|
59
|
+
- `--sandbox` to README module execution options table
|
|
60
|
+
- CHANGELOG.md
|
|
61
|
+
- Core Dispatcher (FE-01): `LazyModuleGroup`, `build_module_command`, `collect_input`, `validate_module_id`
|
|
62
|
+
- Schema Parser (FE-02): `schema_to_click_options`, `_map_type`, `_extract_help`, `reconvert_enum_values`
|
|
63
|
+
- Ref Resolver (FE-02): `resolve_refs`, `_resolve_node` with `$ref`, `allOf`, `anyOf`, `oneOf` support
|
|
64
|
+
- Config Resolver (FE-07): `ConfigResolver` with 4-tier precedence (CLI > Env > File > Default)
|
|
65
|
+
- Approval Gate (FE-03): `check_approval`, `_prompt_with_timeout` with TTY detection and Unix SIGALRM
|
|
66
|
+
- Discovery (FE-04): `list` and `describe` commands with tag filtering and TTY-adaptive output
|
|
67
|
+
- Output Formatter (FE-08): `format_module_list`, `format_module_detail`, `format_exec_result` with Rich rendering
|
|
68
|
+
- Security Manager (FE-05): `AuthProvider`, `ConfigEncryptor` (keyring + AES-256-GCM), `AuditLogger` (JSON Lines), `Sandbox` (subprocess isolation)
|
|
69
|
+
- Shell Integration (FE-06): bash/zsh/fish completion generators, roff man page generator
|
|
70
|
+
- 8 example modules: `math.add`, `math.multiply`, `text.upper`, `text.reverse`, `text.wordcount`, `sysutil.info`, `sysutil.env`, `sysutil.disk`
|
|
71
|
+
- 244 tests (unit, integration, end-to-end)
|
|
72
|
+
- CI workflow with pytest and coverage
|
|
73
|
+
- Pre-commit hooks configuration
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: apcore-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Terminal adapter for apcore — execute AI-Perceivable modules from the command line
|
|
5
5
|
Project-URL: Homepage, https://aipartnerup.com
|
|
6
6
|
Project-URL: Repository, https://github.com/aipartnerup/apcore-cli-python
|
|
@@ -45,7 +45,7 @@ Terminal adapter for apcore. Execute AI-Perceivable modules from the command lin
|
|
|
45
45
|
|
|
46
46
|
[](LICENSE)
|
|
47
47
|
[](https://python.org)
|
|
48
|
-
[]()
|
|
49
49
|
|
|
50
50
|
| | |
|
|
51
51
|
|---|---|
|
|
@@ -207,7 +207,7 @@ apcore-cli [OPTIONS] COMMAND [ARGS]
|
|
|
207
207
|
| Option | Default | Description |
|
|
208
208
|
|--------|---------|-------------|
|
|
209
209
|
| `--extensions-dir` | `./extensions` | Path to apcore extensions directory |
|
|
210
|
-
| `--log-level` | `
|
|
210
|
+
| `--log-level` | `WARNING` | Logging: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
|
211
211
|
| `--version` | | Show version and exit |
|
|
212
212
|
| `--help` | | Show help and exit |
|
|
213
213
|
|
|
@@ -264,7 +264,8 @@ apcore-cli uses a 4-tier configuration precedence:
|
|
|
264
264
|
|----------|-------------|---------|
|
|
265
265
|
| `APCORE_EXTENSIONS_ROOT` | Path to extensions directory | `./extensions` |
|
|
266
266
|
| `APCORE_CLI_AUTO_APPROVE` | Set to `1` to bypass all approval prompts | *(unset)* |
|
|
267
|
-
| `
|
|
267
|
+
| `APCORE_CLI_LOGGING_LEVEL` | CLI-specific log level (takes priority over `APCORE_LOGGING_LEVEL`) | `WARNING` |
|
|
268
|
+
| `APCORE_LOGGING_LEVEL` | Global apcore log level (fallback when `APCORE_CLI_LOGGING_LEVEL` is unset) | `WARNING` |
|
|
268
269
|
| `APCORE_AUTH_API_KEY` | API key for remote registry authentication | *(unset)* |
|
|
269
270
|
| `APCORE_CLI_SANDBOX` | Set to `1` to enable subprocess sandboxing | *(unset)* |
|
|
270
271
|
|
|
@@ -303,7 +304,7 @@ sandbox:
|
|
|
303
304
|
| `module_id` (`math.add`) | Command name (`apcore-cli math.add`) |
|
|
304
305
|
| `description` | `--help` text |
|
|
305
306
|
| `input_schema.properties` | CLI flags (`--a`, `--b`) |
|
|
306
|
-
| `input_schema.required` |
|
|
307
|
+
| `input_schema.required` | Validated post-collection via `jsonschema.validate()` (required fields shown as `[required]` in `--help`) |
|
|
307
308
|
| `annotations.requires_approval` | HITL approval prompt |
|
|
308
309
|
|
|
309
310
|
### Architecture
|
|
@@ -414,7 +415,7 @@ apcore-cli --extensions-dir ./extensions greet.hello --name Alice --greeting Hi
|
|
|
414
415
|
git clone https://github.com/aipartnerup/apcore-cli-python.git
|
|
415
416
|
cd apcore-cli-python
|
|
416
417
|
pip install -e ".[dev]"
|
|
417
|
-
pytest #
|
|
418
|
+
pytest # 261 tests
|
|
418
419
|
pytest --cov # with coverage report
|
|
419
420
|
bash examples/run_examples.sh # run all examples
|
|
420
421
|
```
|
|
@@ -8,7 +8,7 @@ Terminal adapter for apcore. Execute AI-Perceivable modules from the command lin
|
|
|
8
8
|
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](https://python.org)
|
|
11
|
-
[]()
|
|
12
12
|
|
|
13
13
|
| | |
|
|
14
14
|
|---|---|
|
|
@@ -170,7 +170,7 @@ apcore-cli [OPTIONS] COMMAND [ARGS]
|
|
|
170
170
|
| Option | Default | Description |
|
|
171
171
|
|--------|---------|-------------|
|
|
172
172
|
| `--extensions-dir` | `./extensions` | Path to apcore extensions directory |
|
|
173
|
-
| `--log-level` | `
|
|
173
|
+
| `--log-level` | `WARNING` | Logging: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
|
174
174
|
| `--version` | | Show version and exit |
|
|
175
175
|
| `--help` | | Show help and exit |
|
|
176
176
|
|
|
@@ -227,7 +227,8 @@ apcore-cli uses a 4-tier configuration precedence:
|
|
|
227
227
|
|----------|-------------|---------|
|
|
228
228
|
| `APCORE_EXTENSIONS_ROOT` | Path to extensions directory | `./extensions` |
|
|
229
229
|
| `APCORE_CLI_AUTO_APPROVE` | Set to `1` to bypass all approval prompts | *(unset)* |
|
|
230
|
-
| `
|
|
230
|
+
| `APCORE_CLI_LOGGING_LEVEL` | CLI-specific log level (takes priority over `APCORE_LOGGING_LEVEL`) | `WARNING` |
|
|
231
|
+
| `APCORE_LOGGING_LEVEL` | Global apcore log level (fallback when `APCORE_CLI_LOGGING_LEVEL` is unset) | `WARNING` |
|
|
231
232
|
| `APCORE_AUTH_API_KEY` | API key for remote registry authentication | *(unset)* |
|
|
232
233
|
| `APCORE_CLI_SANDBOX` | Set to `1` to enable subprocess sandboxing | *(unset)* |
|
|
233
234
|
|
|
@@ -266,7 +267,7 @@ sandbox:
|
|
|
266
267
|
| `module_id` (`math.add`) | Command name (`apcore-cli math.add`) |
|
|
267
268
|
| `description` | `--help` text |
|
|
268
269
|
| `input_schema.properties` | CLI flags (`--a`, `--b`) |
|
|
269
|
-
| `input_schema.required` |
|
|
270
|
+
| `input_schema.required` | Validated post-collection via `jsonschema.validate()` (required fields shown as `[required]` in `--help`) |
|
|
270
271
|
| `annotations.requires_approval` | HITL approval prompt |
|
|
271
272
|
|
|
272
273
|
### Architecture
|
|
@@ -377,7 +378,7 @@ apcore-cli --extensions-dir ./extensions greet.hello --name Alice --greeting Hi
|
|
|
377
378
|
git clone https://github.com/aipartnerup/apcore-cli-python.git
|
|
378
379
|
cd apcore-cli-python
|
|
379
380
|
pip install -e ".[dev]"
|
|
380
|
-
pytest #
|
|
381
|
+
pytest # 261 tests
|
|
381
382
|
pytest --cov # with coverage report
|
|
382
383
|
bash examples/run_examples.sh # run all examples
|
|
383
384
|
```
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""math.add — Add two numbers."""
|
|
2
2
|
|
|
3
|
-
from pydantic import BaseModel
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class Input(BaseModel):
|
|
7
|
-
a: int
|
|
8
|
-
b: int
|
|
7
|
+
a: int = Field(..., description="First operand")
|
|
8
|
+
b: int = Field(..., description="Second operand")
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class Output(BaseModel):
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""math.multiply — Multiply two numbers."""
|
|
2
2
|
|
|
3
|
-
from pydantic import BaseModel
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class Input(BaseModel):
|
|
7
|
-
a: int
|
|
8
|
-
b: int
|
|
7
|
+
a: int = Field(..., description="First operand")
|
|
8
|
+
b: int = Field(..., description="Second operand")
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class Output(BaseModel):
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
|
|
5
|
-
from pydantic import BaseModel
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class Input(BaseModel):
|
|
9
|
-
name: str
|
|
10
|
-
default: str = ""
|
|
9
|
+
name: str = Field(..., description="Environment variable name to read")
|
|
10
|
+
default: str = Field("", description="Value to return if the variable is not set")
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class Output(BaseModel):
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""text.upper — Convert text to uppercase."""
|
|
2
2
|
|
|
3
|
-
from pydantic import BaseModel
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class Input(BaseModel):
|
|
7
|
-
text: str
|
|
7
|
+
text: str = Field(..., description="Input string to convert")
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class Output(BaseModel):
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""text.wordcount — Count words, characters, and lines."""
|
|
2
2
|
|
|
3
|
-
from pydantic import BaseModel
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class Input(BaseModel):
|
|
7
|
-
text: str
|
|
7
|
+
text: str = Field(..., description="Input text to analyse")
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class Output(BaseModel):
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""apcore-cli: CLI adapter for the apcore module ecosystem."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _get_version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _get_version("apcore-cli")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = "unknown"
|
|
@@ -36,13 +36,35 @@ def _extract_extensions_dir(argv: list[str] | None = None) -> str | None:
|
|
|
36
36
|
return None
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def create_cli(extensions_dir: str | None = None) -> click.Group:
|
|
39
|
+
def create_cli(extensions_dir: str | None = None, prog_name: str | None = None) -> click.Group:
|
|
40
40
|
"""Create the CLI application.
|
|
41
41
|
|
|
42
42
|
Args:
|
|
43
43
|
extensions_dir: Override for extensions directory.
|
|
44
44
|
When None, resolves via ConfigResolver (env/file/default).
|
|
45
|
+
prog_name: Name shown in help text and version output.
|
|
46
|
+
Defaults to the basename of sys.argv[0], so downstream projects
|
|
47
|
+
that install their own entry-point script get the correct name
|
|
48
|
+
automatically (e.g. ``mycli`` instead of ``apcore-cli``).
|
|
45
49
|
"""
|
|
50
|
+
if prog_name is None:
|
|
51
|
+
prog_name = os.path.basename(sys.argv[0]) or "apcore-cli"
|
|
52
|
+
|
|
53
|
+
# Resolve CLI log level (3-tier precedence, evaluated before Click runs):
|
|
54
|
+
# APCORE_CLI_LOGGING_LEVEL (CLI-specific) > APCORE_LOGGING_LEVEL (global) > WARNING
|
|
55
|
+
# The --log-level flag (parsed later) can further override at runtime.
|
|
56
|
+
_cli_level_str = os.environ.get("APCORE_CLI_LOGGING_LEVEL", "").upper()
|
|
57
|
+
_global_level_str = os.environ.get("APCORE_LOGGING_LEVEL", "").upper()
|
|
58
|
+
_active_level_str = _cli_level_str or _global_level_str
|
|
59
|
+
_default_level = getattr(logging, _active_level_str, logging.WARNING) if _active_level_str else logging.WARNING
|
|
60
|
+
logging.basicConfig(level=_default_level, format="%(levelname)s: %(message)s")
|
|
61
|
+
# basicConfig is a no-op if handlers already exist; always set the root level explicitly.
|
|
62
|
+
logging.getLogger().setLevel(_default_level)
|
|
63
|
+
# Silence noisy upstream apcore loggers unless the user requests verbose output.
|
|
64
|
+
# Always set explicitly so the level is deterministic regardless of prior state.
|
|
65
|
+
apcore_level = _default_level if _default_level <= logging.INFO else logging.ERROR
|
|
66
|
+
logging.getLogger("apcore").setLevel(apcore_level)
|
|
67
|
+
|
|
46
68
|
if extensions_dir is not None:
|
|
47
69
|
ext_dir = extensions_dir
|
|
48
70
|
else:
|
|
@@ -97,12 +119,12 @@ def create_cli(extensions_dir: str | None = None) -> click.Group:
|
|
|
97
119
|
cls=LazyModuleGroup,
|
|
98
120
|
registry=registry,
|
|
99
121
|
executor=executor,
|
|
100
|
-
name=
|
|
122
|
+
name=prog_name,
|
|
101
123
|
help="CLI adapter for the apcore module ecosystem.",
|
|
102
124
|
)
|
|
103
125
|
@click.version_option(
|
|
104
126
|
version=__version__,
|
|
105
|
-
prog_name=
|
|
127
|
+
prog_name=prog_name,
|
|
106
128
|
)
|
|
107
129
|
@click.option(
|
|
108
130
|
"--extensions-dir",
|
|
@@ -113,11 +135,18 @@ def create_cli(extensions_dir: str | None = None) -> click.Group:
|
|
|
113
135
|
@click.option(
|
|
114
136
|
"--log-level",
|
|
115
137
|
default=None,
|
|
116
|
-
type=click.Choice(["DEBUG", "INFO", "
|
|
117
|
-
help="Log
|
|
138
|
+
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
|
|
139
|
+
help="Log verbosity. Overrides APCORE_CLI_LOGGING_LEVEL and APCORE_LOGGING_LEVEL env vars.",
|
|
118
140
|
)
|
|
119
141
|
@click.pass_context
|
|
120
142
|
def cli(ctx: click.Context, extensions_dir_opt: str | None = None, log_level: str | None = None) -> None:
|
|
143
|
+
if log_level is not None:
|
|
144
|
+
# basicConfig() is a no-op once handlers exist; set level on the root logger directly.
|
|
145
|
+
level = getattr(logging, log_level.upper(), logging.WARNING)
|
|
146
|
+
logging.getLogger().setLevel(level)
|
|
147
|
+
# Keep apcore logger in sync: verbose when user asks for it, quiet otherwise.
|
|
148
|
+
apcore_level = level if level <= logging.INFO else logging.ERROR
|
|
149
|
+
logging.getLogger("apcore").setLevel(apcore_level)
|
|
121
150
|
ctx.ensure_object(dict)
|
|
122
151
|
ctx.obj["extensions_dir"] = ext_dir
|
|
123
152
|
|
|
@@ -125,16 +154,20 @@ def create_cli(extensions_dir: str | None = None) -> click.Group:
|
|
|
125
154
|
register_discovery_commands(cli, registry)
|
|
126
155
|
|
|
127
156
|
# Register shell integration commands
|
|
128
|
-
register_shell_commands(cli)
|
|
157
|
+
register_shell_commands(cli, prog_name=prog_name)
|
|
129
158
|
|
|
130
159
|
return cli
|
|
131
160
|
|
|
132
161
|
|
|
133
|
-
def main() -> None:
|
|
134
|
-
"""Main entry point for apcore-cli.
|
|
135
|
-
|
|
162
|
+
def main(prog_name: str | None = None) -> None:
|
|
163
|
+
"""Main entry point for apcore-cli.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
prog_name: Override the program name shown in help/version output.
|
|
167
|
+
When None, inferred from sys.argv[0] automatically.
|
|
168
|
+
"""
|
|
136
169
|
ext_dir = _extract_extensions_dir()
|
|
137
|
-
cli = create_cli(extensions_dir=ext_dir)
|
|
170
|
+
cli = create_cli(extensions_dir=ext_dir, prog_name=prog_name)
|
|
138
171
|
cli(standalone_mode=True)
|
|
139
172
|
|
|
140
173
|
|
|
@@ -26,7 +26,7 @@ def _get_annotation(annotations: Any, key: str, default: Any = None) -> Any:
|
|
|
26
26
|
return getattr(annotations, key, default)
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def check_approval(module_def: Any, auto_approve: bool
|
|
29
|
+
def check_approval(module_def: Any, auto_approve: bool) -> None:
|
|
30
30
|
"""Check if module requires approval and handle accordingly.
|
|
31
31
|
|
|
32
32
|
Returns None if approved (or approval not required).
|
|
@@ -113,24 +113,26 @@ def _prompt_unix(module_id: str, timeout: int) -> None:
|
|
|
113
113
|
|
|
114
114
|
try:
|
|
115
115
|
approved = click.confirm("Proceed?", default=False)
|
|
116
|
-
signal.alarm(0)
|
|
117
|
-
signal.signal(signal.SIGALRM, old_handler)
|
|
118
|
-
|
|
119
|
-
if approved:
|
|
120
|
-
logger.info("User approved execution of module '%s'.", module_id)
|
|
121
|
-
return
|
|
122
|
-
else:
|
|
123
|
-
logger.warning("Approval rejected by user for module '%s'.", module_id)
|
|
124
|
-
click.echo("Error: Approval denied.", err=True)
|
|
125
|
-
sys.exit(46)
|
|
126
116
|
except ApprovalTimeoutError:
|
|
127
|
-
signal.signal(signal.SIGALRM, old_handler)
|
|
128
117
|
logger.warning("Approval timed out after %ds for module '%s'.", timeout, module_id)
|
|
129
118
|
click.echo(
|
|
130
119
|
f"Error: Approval prompt timed out after {timeout} seconds.",
|
|
131
120
|
err=True,
|
|
132
121
|
)
|
|
133
122
|
sys.exit(46)
|
|
123
|
+
finally:
|
|
124
|
+
# Always cancel the alarm and restore the previous handler, regardless of
|
|
125
|
+
# how the block exits (normal return, sys.exit, KeyboardInterrupt, etc.).
|
|
126
|
+
signal.alarm(0)
|
|
127
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
128
|
+
|
|
129
|
+
if approved:
|
|
130
|
+
logger.info("User approved execution of module '%s'.", module_id)
|
|
131
|
+
return
|
|
132
|
+
else:
|
|
133
|
+
logger.warning("Approval rejected by user for module '%s'.", module_id)
|
|
134
|
+
click.echo("Error: Approval denied.", err=True)
|
|
135
|
+
sys.exit(46)
|
|
134
136
|
|
|
135
137
|
|
|
136
138
|
def _prompt_windows(module_id: str, timeout: int) -> None:
|
|
@@ -32,8 +32,8 @@ BUILTIN_COMMANDS = ["exec", "list", "describe", "completion", "man"]
|
|
|
32
32
|
_audit_logger: AuditLogger | None = None
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def set_audit_logger(audit_logger: AuditLogger) -> None:
|
|
36
|
-
"""Set the global audit logger instance."""
|
|
35
|
+
def set_audit_logger(audit_logger: AuditLogger | None) -> None:
|
|
36
|
+
"""Set the global audit logger instance. Pass None to clear."""
|
|
37
37
|
global _audit_logger
|
|
38
38
|
_audit_logger = audit_logger
|
|
39
39
|
|
|
@@ -131,7 +131,8 @@ def build_module_command(module_def: ModuleDescriptor, executor: Executor) -> cl
|
|
|
131
131
|
resolved_schema = resolve_refs(input_schema, max_depth=32, module_id=module_id)
|
|
132
132
|
except SystemExit:
|
|
133
133
|
raise
|
|
134
|
-
except Exception:
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.warning("Failed to resolve $refs in schema for '%s', using raw schema: %s", module_id, e)
|
|
135
136
|
resolved_schema = input_schema
|
|
136
137
|
else:
|
|
137
138
|
resolved_schema = input_schema
|
|
@@ -166,7 +167,7 @@ def build_module_command(module_def: ModuleDescriptor, executor: Executor) -> cl
|
|
|
166
167
|
sys.exit(45)
|
|
167
168
|
|
|
168
169
|
# 4. Check approval gate
|
|
169
|
-
check_approval(module_def, auto_approve
|
|
170
|
+
check_approval(module_def, auto_approve)
|
|
170
171
|
|
|
171
172
|
# 5. Execute with timing (optionally sandboxed)
|
|
172
173
|
audit_start = time.monotonic()
|
|
@@ -245,6 +246,17 @@ def build_module_command(module_def: ModuleDescriptor, executor: Executor) -> cl
|
|
|
245
246
|
)
|
|
246
247
|
)
|
|
247
248
|
|
|
249
|
+
# Guard: schema property names must not collide with built-in option names.
|
|
250
|
+
_reserved = {"input", "yes", "large_input", "format", "sandbox"}
|
|
251
|
+
for opt in schema_options:
|
|
252
|
+
if opt.name in _reserved:
|
|
253
|
+
click.echo(
|
|
254
|
+
f"Error: Module '{module_id}' schema property '{opt.name}' conflicts "
|
|
255
|
+
f"with a reserved CLI option name. Rename the property.",
|
|
256
|
+
err=True,
|
|
257
|
+
)
|
|
258
|
+
sys.exit(2)
|
|
259
|
+
|
|
248
260
|
# Add schema-generated options
|
|
249
261
|
cmd.params.extend(schema_options)
|
|
250
262
|
|
|
@@ -290,7 +302,7 @@ def collect_input(
|
|
|
290
302
|
)
|
|
291
303
|
sys.exit(2)
|
|
292
304
|
|
|
293
|
-
if not raw
|
|
305
|
+
if not raw:
|
|
294
306
|
stdin_data: dict[str, Any] = {}
|
|
295
307
|
else:
|
|
296
308
|
try:
|
|
@@ -37,6 +37,7 @@ def register_discovery_commands(cli: click.Group, registry: Any) -> None:
|
|
|
37
37
|
help="Output format. Default: table (TTY) or json (non-TTY).",
|
|
38
38
|
)
|
|
39
39
|
def list_cmd(tag: tuple[str, ...], output_format: str | None) -> None:
|
|
40
|
+
"""List available modules in the registry."""
|
|
40
41
|
# Validate tag format
|
|
41
42
|
for t in tag:
|
|
42
43
|
_validate_tag(t)
|
|
@@ -64,6 +65,7 @@ def register_discovery_commands(cli: click.Group, registry: Any) -> None:
|
|
|
64
65
|
help="Output format. Default: table (TTY) or json (non-TTY).",
|
|
65
66
|
)
|
|
66
67
|
def describe_cmd(module_id: str, output_format: str | None) -> None:
|
|
68
|
+
"""Show metadata, schema, and annotations for a module."""
|
|
67
69
|
validate_module_id(module_id)
|
|
68
70
|
|
|
69
71
|
module_def = registry.get_definition(module_id)
|
|
@@ -179,10 +179,24 @@ def format_module_detail(module_def: ModuleDescriptor, format: str) -> None:
|
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
def format_exec_result(result: Any, format: str | None = None) -> None:
|
|
182
|
-
"""Format and print module execution result.
|
|
182
|
+
"""Format and print module execution result.
|
|
183
|
+
|
|
184
|
+
Uses ``resolve_format(format)`` for TTY-adaptive defaulting:
|
|
185
|
+
- json (or non-TTY default): JSON-pretty-printed output.
|
|
186
|
+
- table: Rich table for dict results; falls back to JSON for lists,
|
|
187
|
+
plain string for scalars.
|
|
188
|
+
"""
|
|
183
189
|
if result is None:
|
|
184
190
|
return
|
|
185
|
-
|
|
191
|
+
effective = resolve_format(format)
|
|
192
|
+
if effective == "table" and isinstance(result, dict):
|
|
193
|
+
table = Table()
|
|
194
|
+
table.add_column("Key")
|
|
195
|
+
table.add_column("Value")
|
|
196
|
+
for k, v in result.items():
|
|
197
|
+
table.add_row(str(k), str(v))
|
|
198
|
+
Console().print(table)
|
|
199
|
+
elif isinstance(result, dict | list):
|
|
186
200
|
click.echo(json.dumps(result, indent=2, default=str))
|
|
187
201
|
elif isinstance(result, str):
|
|
188
202
|
click.echo(result)
|
|
@@ -93,7 +93,11 @@ def schema_to_click_options(schema: dict) -> list[click.Option]:
|
|
|
93
93
|
|
|
94
94
|
click_type = _map_type(prop_name, prop_schema)
|
|
95
95
|
is_required = prop_name in required_list
|
|
96
|
-
|
|
96
|
+
_help_base = _extract_help(prop_schema)
|
|
97
|
+
# Append [required] to help text for user clarity; do NOT set required=True
|
|
98
|
+
# at the Click level because that would block --input - (STDIN) from working.
|
|
99
|
+
# Schema-level required validation happens in the callback via jsonschema.validate().
|
|
100
|
+
help_text = ((_help_base + " ") if _help_base else "") + "[required]" if is_required else _help_base
|
|
97
101
|
default = prop_schema.get("default", None)
|
|
98
102
|
|
|
99
103
|
if click_type is _BOOLEAN_FLAG:
|
|
@@ -116,7 +120,7 @@ def schema_to_click_options(schema: dict) -> list[click.Option]:
|
|
|
116
120
|
option = click.Option(
|
|
117
121
|
[flag_name],
|
|
118
122
|
type=click.STRING,
|
|
119
|
-
required=
|
|
123
|
+
required=False,
|
|
120
124
|
default=default,
|
|
121
125
|
help=help_text,
|
|
122
126
|
)
|
|
@@ -125,7 +129,7 @@ def schema_to_click_options(schema: dict) -> list[click.Option]:
|
|
|
125
129
|
option = click.Option(
|
|
126
130
|
[flag_name],
|
|
127
131
|
type=click.Choice(string_values),
|
|
128
|
-
required=
|
|
132
|
+
required=False,
|
|
129
133
|
default=str(default) if default is not None else None,
|
|
130
134
|
help=help_text,
|
|
131
135
|
)
|
|
@@ -135,7 +139,7 @@ def schema_to_click_options(schema: dict) -> list[click.Option]:
|
|
|
135
139
|
option = click.Option(
|
|
136
140
|
[flag_name],
|
|
137
141
|
type=click_type,
|
|
138
|
-
required=
|
|
142
|
+
required=False,
|
|
139
143
|
default=default,
|
|
140
144
|
help=help_text,
|
|
141
145
|
)
|
|
@@ -6,6 +6,7 @@ import hashlib
|
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
|
+
import secrets
|
|
9
10
|
from datetime import UTC, datetime
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Literal
|
|
@@ -35,7 +36,7 @@ class AuditLogger:
|
|
|
35
36
|
"timestamp": datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
|
36
37
|
"user": self._get_user(),
|
|
37
38
|
"module_id": module_id,
|
|
38
|
-
"input_hash":
|
|
39
|
+
"input_hash": self._hash_input(input_data),
|
|
39
40
|
"status": status,
|
|
40
41
|
"exit_code": exit_code,
|
|
41
42
|
"duration_ms": duration_ms,
|
|
@@ -46,8 +47,21 @@ class AuditLogger:
|
|
|
46
47
|
except OSError as e:
|
|
47
48
|
logger.warning("Could not write audit log: %s", e)
|
|
48
49
|
|
|
50
|
+
def _hash_input(self, input_data: dict) -> str:
|
|
51
|
+
"""Hash input with a random salt to prevent correlation across invocations."""
|
|
52
|
+
salt = secrets.token_bytes(16)
|
|
53
|
+
payload = json.dumps(input_data, sort_keys=True).encode()
|
|
54
|
+
return hashlib.sha256(salt + payload).hexdigest()
|
|
55
|
+
|
|
49
56
|
def _get_user(self) -> str:
|
|
50
57
|
try:
|
|
51
58
|
return os.getlogin()
|
|
52
59
|
except OSError:
|
|
53
|
-
|
|
60
|
+
pass
|
|
61
|
+
try:
|
|
62
|
+
import pwd
|
|
63
|
+
|
|
64
|
+
return pwd.getpwuid(os.getuid()).pw_name
|
|
65
|
+
except (ImportError, KeyError, AttributeError):
|
|
66
|
+
pass
|
|
67
|
+
return os.getenv("USER", os.getenv("USERNAME", "unknown"))
|