Skip to content

Commit 9aee0b5

Browse files
committed
feat: implement man page rendering, add Config Bus namespace support, and enable backward-compatible configuration mapping
1 parent 6e69e57 commit 9aee0b5

7 files changed

Lines changed: 148 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "apcore-cli"
7-
version = "0.4.1"
7+
version = "0.5.0"
88
description = "Terminal adapter for apcore — execute AI-Perceivable modules from the command line"
99
readme = "README.md"
1010
license = "Apache-2.0"
@@ -26,7 +26,7 @@ classifiers = [
2626
"Environment :: Console",
2727
]
2828
dependencies = [
29-
"apcore>=0.14.0",
29+
"apcore>=0.15.1",
3030
"click>=8.1",
3131
"jsonschema>=4.20",
3232
"rich>=13.0",

src/apcore_cli/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,21 @@
77
__version__ = _get_version("apcore-cli")
88
except PackageNotFoundError:
99
__version__ = "unknown"
10+
11+
# Config Bus namespace registration (apcore >= 0.15.0)
12+
try:
13+
from apcore import Config
14+
15+
Config.register_namespace(
16+
name="apcore-cli",
17+
schema=None,
18+
env_prefix="APCORE_CLI",
19+
defaults={
20+
"stdin_buffer_limit": 10_485_760,
21+
"auto_approve": False,
22+
"help_text_max_length": 1000,
23+
"logging_level": "WARNING",
24+
},
25+
)
26+
except (ImportError, AttributeError):
27+
pass # apcore < 0.15.0 or not installed

src/apcore_cli/__main__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from apcore_cli.config import ConfigResolver
1414
from apcore_cli.discovery import register_discovery_commands
1515
from apcore_cli.security.audit import AuditLogger
16-
from apcore_cli.shell import register_shell_commands
16+
from apcore_cli.shell import configure_man_help, register_shell_commands
1717

1818
logger = logging.getLogger("apcore_cli")
1919

@@ -256,6 +256,9 @@ def cli(
256256
# Register shell integration commands
257257
register_shell_commands(cli, prog_name=prog_name)
258258

259+
# Register --help --man support
260+
configure_man_help(cli, prog_name, __version__)
261+
259262
# Register init scaffolding command
260263
from apcore_cli.init_cmd import register_init_command
261264

src/apcore_cli/cli.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,13 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non
343343
with formatter.section("Groups"):
344344
formatter.write_dl(group_records)
345345

346+
# Footer hints for discoverability
347+
formatter.write_paragraph()
348+
formatter.write(
349+
"Use --help --verbose to show all options (including built-in apcore options).\n"
350+
"Use --help --man to display a formatted man page."
351+
)
352+
346353

347354
# Error code mapping from apcore error codes to CLI exit codes
348355
_ERROR_CODE_MAP = {
@@ -359,6 +366,13 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non
359366
"MODULE_EXECUTE_ERROR": 1,
360367
"MODULE_TIMEOUT": 1,
361368
"ACL_DENIED": 77,
369+
# Config Bus errors (apcore >= 0.15.0)
370+
"CONFIG_NAMESPACE_RESERVED": 78,
371+
"CONFIG_NAMESPACE_DUPLICATE": 78,
372+
"CONFIG_ENV_PREFIX_CONFLICT": 78,
373+
"CONFIG_MOUNT_ERROR": 66,
374+
"CONFIG_BIND_ERROR": 65,
375+
"ERROR_FORMATTER_DUPLICATE": 70,
362376
}
363377

364378

src/apcore_cli/config.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,27 @@ class ConfigResolver:
1616
CLI flag > Environment variable > Config file > Default.
1717
"""
1818

19+
# Namespace key -> legacy key mapping for backward compatibility
20+
_NAMESPACE_TO_LEGACY: dict[str, str] = {
21+
"apcore-cli.stdin_buffer_limit": "cli.stdin_buffer_limit",
22+
"apcore-cli.auto_approve": "cli.auto_approve",
23+
"apcore-cli.help_text_max_length": "cli.help_text_max_length",
24+
"apcore-cli.logging_level": "logging.level",
25+
}
26+
_LEGACY_TO_NAMESPACE: dict[str, str] = {v: k for k, v in _NAMESPACE_TO_LEGACY.items()}
27+
1928
DEFAULTS: dict[str, Any] = {
2029
"extensions.root": "./extensions",
2130
"logging.level": "WARNING",
2231
"sandbox.enabled": False,
2332
"cli.stdin_buffer_limit": 10_485_760, # 10 MB
2433
"cli.auto_approve": False,
2534
"cli.help_text_max_length": 1000,
35+
# Namespace-mode aliases (apcore >= 0.15.0 Config Bus)
36+
"apcore-cli.stdin_buffer_limit": 10_485_760,
37+
"apcore-cli.auto_approve": False,
38+
"apcore-cli.help_text_max_length": 1000,
39+
"apcore-cli.logging_level": "WARNING",
2640
}
2741

2842
def __init__(
@@ -53,9 +67,13 @@ def resolve(
5367
if env_value is not None and env_value != "":
5468
return env_value
5569

56-
# Tier 3: Config file
57-
if self._config_file is not None and key in self._config_file:
58-
return self._config_file[key]
70+
# Tier 3: Config file (try both namespace and legacy keys)
71+
if self._config_file is not None:
72+
if key in self._config_file:
73+
return self._config_file[key]
74+
alt_key = self._NAMESPACE_TO_LEGACY.get(key) or self._LEGACY_TO_NAMESPACE.get(key)
75+
if alt_key and alt_key in self._config_file:
76+
return self._config_file[alt_key]
5977

6078
# Tier 4: Default
6179
return self.DEFAULTS.get(key)

src/apcore_cli/shell.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from __future__ import annotations
44

5+
import os
56
import re
67
import shlex
8+
import subprocess
79
import sys
810
from datetime import date
911

@@ -480,6 +482,48 @@ def build_program_man_page(
480482
return "\n".join(s)
481483

482484

485+
def _render_man_page(roff: str) -> None:
486+
"""Render a roff man page to stdout.
487+
488+
When stdout is a TTY, attempts to render through mandoc or groff and
489+
pipe through a pager for formatted display. When stdout is not a TTY
490+
(piped or redirected), outputs raw roff for file redirection.
491+
"""
492+
roff_bytes = roff.encode()
493+
if not sys.stdout.isatty():
494+
sys.stdout.write(roff)
495+
return
496+
497+
# Try mandoc first (macOS/BSD), then groff
498+
renderers = [
499+
["mandoc", "-a"],
500+
["groff", "-man", "-Tutf8"],
501+
]
502+
for cmd in renderers:
503+
try:
504+
result = subprocess.run(
505+
cmd,
506+
input=roff_bytes,
507+
capture_output=True,
508+
)
509+
except FileNotFoundError:
510+
continue
511+
if result.returncode == 0 and result.stdout:
512+
pager = os.environ.get("PAGER", "less")
513+
try:
514+
subprocess.run(
515+
[pager, "-R"],
516+
input=result.stdout,
517+
)
518+
return
519+
except FileNotFoundError:
520+
# Pager not found — fall through to raw output
521+
break
522+
523+
# Fallback: raw roff output
524+
sys.stdout.write(roff)
525+
526+
483527
def configure_man_help(
484528
cli: click.Group,
485529
prog_name: str,
@@ -520,7 +564,7 @@ def configure_man_help(
520564
args = sys.argv[1:]
521565
if "--man" in args and ("--help" in args or "-h" in args):
522566
roff = build_program_man_page(cli, prog_name, version, description, docs_url)
523-
click.echo(roff)
567+
_render_man_page(roff)
524568
sys.exit(0)
525569

526570

tests/test_config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,47 @@ def test_flatten_dict_deeply_nested(self):
140140
resolver = ConfigResolver(config_path="/nonexistent/apcore.yaml")
141141
result = resolver._flatten_dict({"a": {"b": {"c": "deep_value"}}})
142142
assert result == {"a.b.c": "deep_value"}
143+
144+
145+
class TestNamespaceAwareConfigResolution:
146+
"""Config Bus namespace ↔ legacy key fallback (apcore >= 0.15.0)."""
147+
148+
def test_defaults_contain_namespace_keys(self):
149+
resolver = ConfigResolver()
150+
for key in [
151+
"apcore-cli.stdin_buffer_limit",
152+
"apcore-cli.auto_approve",
153+
"apcore-cli.help_text_max_length",
154+
"apcore-cli.logging_level",
155+
]:
156+
assert key in resolver.DEFAULTS, f"Missing namespace default: {key}"
157+
158+
def test_resolve_namespace_key_from_legacy_config_file(self, tmp_path, clean_env):
159+
"""Querying 'apcore-cli.stdin_buffer_limit' finds 'cli.stdin_buffer_limit' in file."""
160+
config_file = tmp_path / "apcore.yaml"
161+
config_file.write_text("cli:\n stdin_buffer_limit: 5242880\n")
162+
resolver = ConfigResolver(config_path=str(config_file))
163+
result = resolver.resolve("apcore-cli.stdin_buffer_limit")
164+
assert result == 5242880
165+
166+
def test_resolve_legacy_key_from_namespace_config_file(self, tmp_path, clean_env):
167+
"""Querying 'cli.auto_approve' finds 'apcore-cli.auto_approve' in file."""
168+
config_file = tmp_path / "apcore.yaml"
169+
config_file.write_text("apcore-cli:\n auto_approve: true\n")
170+
resolver = ConfigResolver(config_path=str(config_file))
171+
result = resolver.resolve("cli.auto_approve")
172+
assert result is True
173+
174+
def test_direct_key_takes_precedence_over_alternate(self, tmp_path, clean_env):
175+
"""When both keys exist in file, the directly-queried key wins."""
176+
config_file = tmp_path / "apcore.yaml"
177+
config_file.write_text("cli:\n help_text_max_length: 500\n" "apcore-cli:\n help_text_max_length: 2000\n")
178+
resolver = ConfigResolver(config_path=str(config_file))
179+
assert resolver.resolve("cli.help_text_max_length") == 500
180+
assert resolver.resolve("apcore-cli.help_text_max_length") == 2000
181+
182+
def test_namespace_mapping_is_bidirectional(self):
183+
resolver = ConfigResolver()
184+
assert len(resolver._NAMESPACE_TO_LEGACY) == len(resolver._LEGACY_TO_NAMESPACE)
185+
for ns_key, legacy_key in resolver._NAMESPACE_TO_LEGACY.items():
186+
assert resolver._LEGACY_TO_NAMESPACE[legacy_key] == ns_key

0 commit comments

Comments
 (0)