Skip to content

Commit ee0b48c

Browse files
committed
fix: Resolve STDIN input issues, improve logging precedence, add Rich table output, and enhance completion/man page generation with robust program name handling.
1 parent 307e968 commit ee0b48c

File tree

23 files changed

+596
-210
lines changed

23 files changed

+596
-210
lines changed

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@ All notable changes to apcore-cli (Python SDK) will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

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+
844
## [0.1.0] - 2026-03-15
945

1046
### Added

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Terminal adapter for apcore. Execute AI-Perceivable modules from the command lin
88

99
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
1010
[![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)]()
1212

1313
| | |
1414
|---|---|
@@ -170,7 +170,7 @@ apcore-cli [OPTIONS] COMMAND [ARGS]
170170
| Option | Default | Description |
171171
|--------|---------|-------------|
172172
| `--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` |
174174
| `--version` | | Show version and exit |
175175
| `--help` | | Show help and exit |
176176

@@ -227,7 +227,8 @@ apcore-cli uses a 4-tier configuration precedence:
227227
|----------|-------------|---------|
228228
| `APCORE_EXTENSIONS_ROOT` | Path to extensions directory | `./extensions` |
229229
| `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` |
231232
| `APCORE_AUTH_API_KEY` | API key for remote registry authentication | *(unset)* |
232233
| `APCORE_CLI_SANDBOX` | Set to `1` to enable subprocess sandboxing | *(unset)* |
233234

@@ -266,7 +267,7 @@ sandbox:
266267
| `module_id` (`math.add`) | Command name (`apcore-cli math.add`) |
267268
| `description` | `--help` text |
268269
| `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`) |
270271
| `annotations.requires_approval` | HITL approval prompt |
271272

272273
### Architecture
@@ -377,7 +378,7 @@ apcore-cli --extensions-dir ./extensions greet.hello --name Alice --greeting Hi
377378
git clone https://github.com/aipartnerup/apcore-cli-python.git
378379
cd apcore-cli-python
379380
pip install -e ".[dev]"
380-
pytest # 244 tests
381+
pytest # 261 tests
381382
pytest --cov # with coverage report
382383
bash examples/run_examples.sh # run all examples
383384
```

examples/extensions/math/add.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
"""math.add — Add two numbers."""
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, Field
44

55

66
class Input(BaseModel):
7-
a: int
8-
b: int
7+
a: int = Field(..., description="First operand")
8+
b: int = Field(..., description="Second operand")
99

1010

1111
class Output(BaseModel):

examples/extensions/math/multiply.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
"""math.multiply — Multiply two numbers."""
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, Field
44

55

66
class Input(BaseModel):
7-
a: int
8-
b: int
7+
a: int = Field(..., description="First operand")
8+
b: int = Field(..., description="Second operand")
99

1010

1111
class Output(BaseModel):

examples/extensions/sysutil/disk.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
import os
44

5-
from pydantic import BaseModel
5+
from pydantic import BaseModel, Field
66

77

88
class Input(BaseModel):
9-
path: str = "/"
9+
path: str = Field("/", description="Filesystem path to check (default: /)")
1010

1111

1212
class Output(BaseModel):

examples/extensions/sysutil/env.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
import os
44

5-
from pydantic import BaseModel
5+
from pydantic import BaseModel, Field
66

77

88
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")
1111

1212

1313
class Output(BaseModel):

examples/extensions/text/reverse.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""text.reverse — Reverse a string."""
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, Field
44

55

66
class Input(BaseModel):
7-
text: str
7+
text: str = Field(..., description="Input string to reverse")
88

99

1010
class Output(BaseModel):

examples/extensions/text/upper.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""text.upper — Convert text to uppercase."""
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, Field
44

55

66
class Input(BaseModel):
7-
text: str
7+
text: str = Field(..., description="Input string to convert")
88

99

1010
class Output(BaseModel):

examples/extensions/text/wordcount.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""text.wordcount — Count words, characters, and lines."""
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, Field
44

55

66
class Input(BaseModel):
7-
text: str
7+
text: str = Field(..., description="Input text to analyse")
88

99

1010
class Output(BaseModel):

src/apcore_cli/__main__.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,35 @@ def _extract_extensions_dir(argv: list[str] | None = None) -> str | None:
3636
return None
3737

3838

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:
4040
"""Create the CLI application.
4141
4242
Args:
4343
extensions_dir: Override for extensions directory.
4444
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``).
4549
"""
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+
4668
if extensions_dir is not None:
4769
ext_dir = extensions_dir
4870
else:
@@ -97,12 +119,12 @@ def create_cli(extensions_dir: str | None = None) -> click.Group:
97119
cls=LazyModuleGroup,
98120
registry=registry,
99121
executor=executor,
100-
name="apcore-cli",
122+
name=prog_name,
101123
help="CLI adapter for the apcore module ecosystem.",
102124
)
103125
@click.version_option(
104126
version=__version__,
105-
prog_name="apcore-cli",
127+
prog_name=prog_name,
106128
)
107129
@click.option(
108130
"--extensions-dir",
@@ -113,28 +135,39 @@ def create_cli(extensions_dir: str | None = None) -> click.Group:
113135
@click.option(
114136
"--log-level",
115137
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.",
118140
)
119141
@click.pass_context
120142
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)
121150
ctx.ensure_object(dict)
122151
ctx.obj["extensions_dir"] = ext_dir
123152

124153
# Register discovery commands
125154
register_discovery_commands(cli, registry)
126155

127156
# Register shell integration commands
128-
register_shell_commands(cli)
157+
register_shell_commands(cli, prog_name=prog_name)
129158

130159
return cli
131160

132161

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+
"""
136169
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)
138171
cli(standalone_mode=True)
139172

140173

0 commit comments

Comments
 (0)