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.
Files changed (67) hide show
  1. apcore_cli-0.2.0/CHANGELOG.md +73 -0
  2. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/PKG-INFO +7 -6
  3. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/README.md +6 -5
  4. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/math/add.py +3 -3
  5. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/math/multiply.py +3 -3
  6. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/sysutil/disk.py +2 -2
  7. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/sysutil/env.py +3 -3
  8. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/text/reverse.py +2 -2
  9. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/text/upper.py +2 -2
  10. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/text/wordcount.py +2 -2
  11. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/pyproject.toml +1 -1
  12. apcore_cli-0.2.0/src/apcore_cli/__init__.py +9 -0
  13. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/__main__.py +43 -10
  14. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/approval.py +14 -12
  15. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/cli.py +17 -5
  16. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/discovery.py +2 -0
  17. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/output.py +16 -2
  18. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/schema_parser.py +8 -4
  19. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/security/audit.py +16 -2
  20. apcore_cli-0.2.0/src/apcore_cli/shell.py +290 -0
  21. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_approval.py +15 -30
  22. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_cli.py +74 -2
  23. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_e2e.py +3 -1
  24. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_integration.py +58 -33
  25. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_schema_parser.py +5 -1
  26. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_security/test_audit.py +27 -8
  27. apcore_cli-0.2.0/tests/test_shell.py +186 -0
  28. apcore_cli-0.1.0/CHANGELOG.md +0 -37
  29. apcore_cli-0.1.0/src/apcore_cli/__init__.py +0 -3
  30. apcore_cli-0.1.0/src/apcore_cli/shell.py +0 -185
  31. apcore_cli-0.1.0/tests/test_shell.py +0 -126
  32. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.github/CODEOWNERS +0 -0
  33. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.github/copilot-ignore +0 -0
  34. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.github/workflows/ci.yml +0 -0
  35. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.gitignore +0 -0
  36. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.gitmessage +0 -0
  37. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/.pre-commit-config.yaml +0 -0
  38. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/extensions/sysutil/info.py +0 -0
  39. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/examples/run_examples.sh +0 -0
  40. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/approval-gate.md +0 -0
  41. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/config-resolver.md +0 -0
  42. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/core-dispatcher.md +0 -0
  43. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/discovery.md +0 -0
  44. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/output-formatter.md +0 -0
  45. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/overview.md +0 -0
  46. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/schema-parser.md +0 -0
  47. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/security-manager.md +0 -0
  48. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/shell-integration.md +0 -0
  49. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/planning/state.json +0 -0
  50. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/_sandbox_runner.py +0 -0
  51. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/config.py +0 -0
  52. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/ref_resolver.py +0 -0
  53. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/security/__init__.py +0 -0
  54. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/security/auth.py +0 -0
  55. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/security/config_encryptor.py +0 -0
  56. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/src/apcore_cli/security/sandbox.py +0 -0
  57. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/__init__.py +0 -0
  58. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/conftest.py +0 -0
  59. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_bugfixes.py +0 -0
  60. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_config.py +0 -0
  61. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_discovery.py +0 -0
  62. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_output.py +0 -0
  63. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_ref_resolver.py +0 -0
  64. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_security/__init__.py +0 -0
  65. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_security/test_auth.py +0 -0
  66. {apcore_cli-0.1.0 → apcore_cli-0.2.0}/tests/test_security/test_config_encryptor.py +0 -0
  67. {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.1.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](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
47
47
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://python.org)
48
- [![Tests](https://img.shields.io/badge/tests-244%20passed-brightgreen.svg)]()
48
+ [![Tests](https://img.shields.io/badge/tests-261%20passed-brightgreen.svg)]()
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` | `INFO` | Logging: `DEBUG`, `INFO`, `WARN`, `ERROR` |
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
- | `APCORE_LOGGING_LEVEL` | Log level | `INFO` |
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` | Required flag enforcement |
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 # 244 tests
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](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
10
10
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://python.org)
11
- [![Tests](https://img.shields.io/badge/tests-244%20passed-brightgreen.svg)]()
11
+ [![Tests](https://img.shields.io/badge/tests-261%20passed-brightgreen.svg)]()
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` | `INFO` | Logging: `DEBUG`, `INFO`, `WARN`, `ERROR` |
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
- | `APCORE_LOGGING_LEVEL` | Log level | `INFO` |
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` | Required flag enforcement |
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 # 244 tests
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,11 +2,11 @@
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
- path: str = "/"
9
+ path: str = Field("/", description="Filesystem path to check (default: /)")
10
10
 
11
11
 
12
12
  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.reverse — Reverse a string."""
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 reverse")
8
8
 
9
9
 
10
10
  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):
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apcore-cli"
7
- version = "0.1.0"
7
+ version = "0.2.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"
@@ -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="apcore-cli",
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="apcore-cli",
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", "WARN", "ERROR"], case_sensitive=False),
117
- help="Log level.",
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
- # Extract --extensions-dir from argv before Click parses
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, ctx: click.Context) -> None:
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, click.get_current_context())
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 or raw_size == 0:
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
- if isinstance(result, dict | list):
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
- help_text = _extract_help(prop_schema)
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=is_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=is_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=is_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": hashlib.sha256(json.dumps(input_data, sort_keys=True).encode()).hexdigest(),
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
- return os.getenv("USER", os.getenv("USERNAME", "unknown"))
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"))