apcore-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- apcore_cli/__main__.py +142 -0
- apcore_cli/_sandbox_runner.py +25 -0
- apcore_cli/approval.py +167 -0
- apcore_cli/cli.py +315 -0
- apcore_cli/config.py +94 -0
- apcore_cli/discovery.py +75 -0
- apcore_cli/output.py +190 -0
- apcore_cli/ref_resolver.py +113 -0
- apcore_cli/schema_parser.py +168 -0
- apcore_cli/security/__init__.py +8 -0
- apcore_cli/security/audit.py +53 -0
- apcore_cli/security/auth.py +37 -0
- apcore_cli/security/config_encryptor.py +94 -0
- apcore_cli/security/sandbox.py +60 -0
- apcore_cli/shell.py +185 -0
- apcore_cli-0.1.0.dist-info/METADATA +459 -0
- apcore_cli-0.1.0.dist-info/RECORD +20 -0
- apcore_cli-0.1.0.dist-info/WHEEL +4 -0
- apcore_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Audit logging in JSON Lines format (FE-05)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("apcore_cli.security")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuditLogger:
|
|
17
|
+
DEFAULT_PATH = Path.home() / ".apcore-cli" / "audit.jsonl"
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
20
|
+
self._path = path or self.DEFAULT_PATH
|
|
21
|
+
self._ensure_directory()
|
|
22
|
+
|
|
23
|
+
def _ensure_directory(self) -> None:
|
|
24
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
def log_execution(
|
|
27
|
+
self,
|
|
28
|
+
module_id: str,
|
|
29
|
+
input_data: dict,
|
|
30
|
+
status: Literal["success", "error"],
|
|
31
|
+
exit_code: int,
|
|
32
|
+
duration_ms: int,
|
|
33
|
+
) -> None:
|
|
34
|
+
entry = {
|
|
35
|
+
"timestamp": datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
|
36
|
+
"user": self._get_user(),
|
|
37
|
+
"module_id": module_id,
|
|
38
|
+
"input_hash": hashlib.sha256(json.dumps(input_data, sort_keys=True).encode()).hexdigest(),
|
|
39
|
+
"status": status,
|
|
40
|
+
"exit_code": exit_code,
|
|
41
|
+
"duration_ms": duration_ms,
|
|
42
|
+
}
|
|
43
|
+
try:
|
|
44
|
+
with open(self._path, "a") as f:
|
|
45
|
+
f.write(json.dumps(entry) + "\n")
|
|
46
|
+
except OSError as e:
|
|
47
|
+
logger.warning("Could not write audit log: %s", e)
|
|
48
|
+
|
|
49
|
+
def _get_user(self) -> str:
|
|
50
|
+
try:
|
|
51
|
+
return os.getlogin()
|
|
52
|
+
except OSError:
|
|
53
|
+
return os.getenv("USER", os.getenv("USERNAME", "unknown"))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""API key authentication (FE-05)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from apcore_cli.config import ConfigResolver
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthenticationError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthProvider:
|
|
16
|
+
def __init__(self, config: ConfigResolver) -> None:
|
|
17
|
+
self._config = config
|
|
18
|
+
|
|
19
|
+
def get_api_key(self) -> str | None:
|
|
20
|
+
result = self._config.resolve("auth.api_key", cli_flag="--api-key", env_var="APCORE_AUTH_API_KEY")
|
|
21
|
+
if result is not None and (result.startswith("keyring:") or result.startswith("enc:")):
|
|
22
|
+
result = self._config.encryptor.retrieve(result, "auth.api_key")
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
def authenticate_request(self, headers: dict) -> dict:
|
|
26
|
+
key = self.get_api_key()
|
|
27
|
+
if key is None:
|
|
28
|
+
raise AuthenticationError(
|
|
29
|
+
"Remote registry requires authentication. "
|
|
30
|
+
"Set --api-key, APCORE_AUTH_API_KEY, or auth.api_key in config."
|
|
31
|
+
)
|
|
32
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
33
|
+
return headers
|
|
34
|
+
|
|
35
|
+
def handle_response(self, status_code: int) -> None:
|
|
36
|
+
if status_code in (401, 403):
|
|
37
|
+
raise AuthenticationError("Authentication failed. Verify your API key.")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Encrypted configuration storage (FE-05)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
|
|
11
|
+
from cryptography.exceptions import InvalidTag
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("apcore_cli.security")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigDecryptionError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigEncryptor:
|
|
21
|
+
SERVICE_NAME = "apcore-cli"
|
|
22
|
+
|
|
23
|
+
def store(self, key: str, value: str) -> str:
|
|
24
|
+
if self._keyring_available():
|
|
25
|
+
import keyring as kr
|
|
26
|
+
|
|
27
|
+
kr.set_password(self.SERVICE_NAME, key, value)
|
|
28
|
+
return f"keyring:{key}"
|
|
29
|
+
else:
|
|
30
|
+
logger.warning("OS keyring unavailable. Using file-based encryption.")
|
|
31
|
+
ciphertext = self._aes_encrypt(value)
|
|
32
|
+
return f"enc:{base64.b64encode(ciphertext).decode()}"
|
|
33
|
+
|
|
34
|
+
def retrieve(self, config_value: str, key: str) -> str:
|
|
35
|
+
if config_value.startswith("keyring:"):
|
|
36
|
+
import keyring as kr
|
|
37
|
+
|
|
38
|
+
ref_key = config_value[len("keyring:") :]
|
|
39
|
+
result = kr.get_password(self.SERVICE_NAME, ref_key)
|
|
40
|
+
if result is None:
|
|
41
|
+
raise ConfigDecryptionError(f"Keyring entry not found for '{ref_key}'.")
|
|
42
|
+
return result
|
|
43
|
+
elif config_value.startswith("enc:"):
|
|
44
|
+
ciphertext = base64.b64decode(config_value[len("enc:") :])
|
|
45
|
+
try:
|
|
46
|
+
return self._aes_decrypt(ciphertext)
|
|
47
|
+
except (InvalidTag, ValueError) as exc:
|
|
48
|
+
raise ConfigDecryptionError(
|
|
49
|
+
f"Failed to decrypt configuration value '{key}'. Re-configure with 'apcore-cli config set {key}'."
|
|
50
|
+
) from exc
|
|
51
|
+
else:
|
|
52
|
+
return config_value
|
|
53
|
+
|
|
54
|
+
def _keyring_available(self) -> bool:
|
|
55
|
+
try:
|
|
56
|
+
import keyring as kr
|
|
57
|
+
|
|
58
|
+
backend = kr.get_keyring()
|
|
59
|
+
return not (
|
|
60
|
+
hasattr(kr, "backends")
|
|
61
|
+
and hasattr(kr.backends, "fail")
|
|
62
|
+
and isinstance(backend, kr.backends.fail.Keyring)
|
|
63
|
+
)
|
|
64
|
+
except Exception:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def _derive_key(self) -> bytes:
|
|
68
|
+
hostname = socket.gethostname()
|
|
69
|
+
username = os.getenv("USER", os.getenv("USERNAME", "unknown"))
|
|
70
|
+
salt = b"apcore-cli-config-v1"
|
|
71
|
+
material = f"{hostname}:{username}".encode()
|
|
72
|
+
return hashlib.pbkdf2_hmac("sha256", material, salt, iterations=100_000)
|
|
73
|
+
|
|
74
|
+
def _aes_encrypt(self, plaintext: str) -> bytes:
|
|
75
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
76
|
+
|
|
77
|
+
key = self._derive_key()
|
|
78
|
+
nonce = os.urandom(12)
|
|
79
|
+
encryptor = Cipher(algorithms.AES(key), modes.GCM(nonce)).encryptor()
|
|
80
|
+
ct = encryptor.update(plaintext.encode("utf-8")) + encryptor.finalize()
|
|
81
|
+
tag = encryptor.tag
|
|
82
|
+
# Wire format: nonce(12) + tag(16) + ciphertext
|
|
83
|
+
return nonce + tag + ct
|
|
84
|
+
|
|
85
|
+
def _aes_decrypt(self, data: bytes) -> str:
|
|
86
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
87
|
+
|
|
88
|
+
key = self._derive_key()
|
|
89
|
+
nonce = data[:12]
|
|
90
|
+
tag = data[12:28]
|
|
91
|
+
ct = data[28:]
|
|
92
|
+
decryptor = Cipher(algorithms.AES(key), modes.GCM(nonce, tag)).decryptor()
|
|
93
|
+
plaintext = decryptor.update(ct) + decryptor.finalize()
|
|
94
|
+
return plaintext.decode("utf-8")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Subprocess-based execution sandboxing (FE-05)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from apcore import Executor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModuleExecutionError(Exception):
|
|
17
|
+
"""Raised when a sandboxed module execution fails."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Sandbox:
|
|
23
|
+
def __init__(self, enabled: bool = False) -> None:
|
|
24
|
+
self._enabled = enabled
|
|
25
|
+
|
|
26
|
+
def execute(self, module_id: str, input_data: dict, executor: Executor) -> Any:
|
|
27
|
+
if not self._enabled:
|
|
28
|
+
return executor.call(module_id, input_data)
|
|
29
|
+
return self._sandboxed_execute(module_id, input_data)
|
|
30
|
+
|
|
31
|
+
def _sandboxed_execute(self, module_id: str, input_data: dict) -> Any:
|
|
32
|
+
env = {}
|
|
33
|
+
for key in ("PATH", "PYTHONPATH", "LANG", "LC_ALL"):
|
|
34
|
+
if key in os.environ:
|
|
35
|
+
env[key] = os.environ[key]
|
|
36
|
+
for key, value in os.environ.items():
|
|
37
|
+
if key.startswith("APCORE_"):
|
|
38
|
+
env[key] = value
|
|
39
|
+
|
|
40
|
+
with tempfile.TemporaryDirectory(prefix="apcore_sandbox_") as tmpdir:
|
|
41
|
+
env["HOME"] = tmpdir
|
|
42
|
+
env["TMPDIR"] = tmpdir
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
[sys.executable, "-m", "apcore_cli._sandbox_runner", module_id],
|
|
47
|
+
input=json.dumps(input_data),
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
env=env,
|
|
51
|
+
cwd=tmpdir,
|
|
52
|
+
timeout=300,
|
|
53
|
+
)
|
|
54
|
+
except subprocess.TimeoutExpired as err:
|
|
55
|
+
raise ModuleExecutionError(f"Error: Module '{module_id}' timed out in sandbox.") from err
|
|
56
|
+
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
raise ModuleExecutionError(f"Error: Module '{module_id}' execution failed: {result.stderr}")
|
|
59
|
+
|
|
60
|
+
return json.loads(result.stdout)
|
apcore_cli/shell.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Shell Integration — completion scripts and man pages (FE-06)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import date
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from apcore_cli import __version__
|
|
11
|
+
|
|
12
|
+
# Shell snippet for dynamic module ID completion (shared across shells)
|
|
13
|
+
_MODULE_LIST_CMD = (
|
|
14
|
+
"apcore-cli list --format json 2>/dev/null"
|
|
15
|
+
' | python3 -c "import sys,json;'
|
|
16
|
+
"[print(m['id']) for m in json.load(sys.stdin)]\" 2>/dev/null"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _generate_bash_completion() -> str:
|
|
21
|
+
return (
|
|
22
|
+
"_apcore_cli_completion() {\n"
|
|
23
|
+
" local cur prev opts\n"
|
|
24
|
+
" COMPREPLY=()\n"
|
|
25
|
+
' cur="${COMP_WORDS[COMP_CWORD]}"\n'
|
|
26
|
+
' prev="${COMP_WORDS[COMP_CWORD-1]}"\n'
|
|
27
|
+
"\n"
|
|
28
|
+
" if [[ ${COMP_CWORD} -eq 1 ]]; then\n"
|
|
29
|
+
' opts="exec list describe completion man"\n'
|
|
30
|
+
' COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )\n'
|
|
31
|
+
" return 0\n"
|
|
32
|
+
" fi\n"
|
|
33
|
+
"\n"
|
|
34
|
+
' if [[ "${COMP_WORDS[1]}" == "exec" && ${COMP_CWORD} -eq 2 ]]; then\n'
|
|
35
|
+
f" local modules=$({_MODULE_LIST_CMD})\n"
|
|
36
|
+
' COMPREPLY=( $(compgen -W "${modules}" -- ${cur}) )\n'
|
|
37
|
+
" return 0\n"
|
|
38
|
+
" fi\n"
|
|
39
|
+
"}\n"
|
|
40
|
+
"complete -F _apcore_cli_completion apcore-cli\n"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _generate_zsh_completion() -> str:
|
|
45
|
+
return (
|
|
46
|
+
"#compdef apcore-cli\n"
|
|
47
|
+
"\n"
|
|
48
|
+
"_apcore_cli() {\n"
|
|
49
|
+
" local -a commands\n"
|
|
50
|
+
" commands=(\n"
|
|
51
|
+
" 'exec:Execute an apcore module'\n"
|
|
52
|
+
" 'list:List available modules'\n"
|
|
53
|
+
" 'describe:Show module details'\n"
|
|
54
|
+
" 'completion:Generate shell completion script'\n"
|
|
55
|
+
" 'man:Generate man page'\n"
|
|
56
|
+
" )\n"
|
|
57
|
+
"\n"
|
|
58
|
+
" _arguments -C \\\n"
|
|
59
|
+
" '1:command:->command' \\\n"
|
|
60
|
+
" '*::arg:->args'\n"
|
|
61
|
+
"\n"
|
|
62
|
+
' case "$state" in\n'
|
|
63
|
+
" command)\n"
|
|
64
|
+
" _describe -t commands 'apcore-cli commands' commands\n"
|
|
65
|
+
" ;;\n"
|
|
66
|
+
" args)\n"
|
|
67
|
+
' case "${words[1]}" in\n'
|
|
68
|
+
" exec)\n"
|
|
69
|
+
" local modules\n"
|
|
70
|
+
f" modules=($({_MODULE_LIST_CMD}))\n"
|
|
71
|
+
" compadd -a modules\n"
|
|
72
|
+
" ;;\n"
|
|
73
|
+
" esac\n"
|
|
74
|
+
" ;;\n"
|
|
75
|
+
" esac\n"
|
|
76
|
+
"}\n"
|
|
77
|
+
"\n"
|
|
78
|
+
"compdef _apcore_cli apcore-cli\n"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _generate_fish_completion() -> str:
|
|
83
|
+
# Build the dynamic completion command for fish (needs escaped quotes)
|
|
84
|
+
fish_dyn = _MODULE_LIST_CMD.replace('"', '\\"').replace("'", "\\'")
|
|
85
|
+
return (
|
|
86
|
+
"# Fish completions for apcore-cli\n"
|
|
87
|
+
'complete -c apcore-cli -n "__fish_use_subcommand"'
|
|
88
|
+
' -a exec -d "Execute an apcore module"\n'
|
|
89
|
+
'complete -c apcore-cli -n "__fish_use_subcommand"'
|
|
90
|
+
' -a list -d "List available modules"\n'
|
|
91
|
+
'complete -c apcore-cli -n "__fish_use_subcommand"'
|
|
92
|
+
' -a describe -d "Show module details"\n'
|
|
93
|
+
'complete -c apcore-cli -n "__fish_use_subcommand"'
|
|
94
|
+
' -a completion -d "Generate shell completion script"\n'
|
|
95
|
+
'complete -c apcore-cli -n "__fish_use_subcommand"'
|
|
96
|
+
' -a man -d "Generate man page"\n'
|
|
97
|
+
"\n"
|
|
98
|
+
'complete -c apcore-cli -n "__fish_seen_subcommand_from exec"'
|
|
99
|
+
f' -a "({fish_dyn})"\n'
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _generate_man_page(command_name: str, command: click.Command | None, cli: click.Group) -> str:
|
|
104
|
+
"""Generate a roff-formatted man page for a command."""
|
|
105
|
+
today = date.today().strftime("%Y-%m-%d")
|
|
106
|
+
cmd_upper = command_name.upper()
|
|
107
|
+
|
|
108
|
+
sections = []
|
|
109
|
+
sections.append(f'.TH "APCORE-CLI-{cmd_upper}" "1" "{today}" "apcore-cli {__version__}" "apcore-cli Manual"')
|
|
110
|
+
sections.append(".SH NAME")
|
|
111
|
+
if command:
|
|
112
|
+
desc = command.help or command_name
|
|
113
|
+
sections.append(f"apcore-cli-{command_name} \\- {desc}")
|
|
114
|
+
else:
|
|
115
|
+
sections.append(f"apcore-cli-{command_name}")
|
|
116
|
+
|
|
117
|
+
sections.append(".SH SYNOPSIS")
|
|
118
|
+
sections.append(f"\\fBapcore-cli {command_name}\\fR [OPTIONS] [ARGUMENTS]")
|
|
119
|
+
|
|
120
|
+
if command and command.help:
|
|
121
|
+
sections.append(".SH DESCRIPTION")
|
|
122
|
+
sections.append(command.help)
|
|
123
|
+
|
|
124
|
+
if command and command.params:
|
|
125
|
+
sections.append(".SH OPTIONS")
|
|
126
|
+
for param in command.params:
|
|
127
|
+
if isinstance(param, click.Option):
|
|
128
|
+
flag = ", ".join(param.opts + getattr(param, "secondary_opts", []))
|
|
129
|
+
type_name = param.type.name if hasattr(param.type, "name") else "VALUE"
|
|
130
|
+
sections.append(".TP")
|
|
131
|
+
sections.append(f"\\fB{flag}\\fR \\fI{type_name}\\fR")
|
|
132
|
+
if param.help:
|
|
133
|
+
sections.append(param.help)
|
|
134
|
+
|
|
135
|
+
sections.append(".SH EXIT CODES")
|
|
136
|
+
sections.append(".TP\n\\fB0\\fR\nSuccess.")
|
|
137
|
+
sections.append(".TP\n\\fB1\\fR\nModule execution error.")
|
|
138
|
+
sections.append(".TP\n\\fB2\\fR\nInvalid CLI input.")
|
|
139
|
+
sections.append(".TP\n\\fB44\\fR\nModule not found, disabled, or load error.")
|
|
140
|
+
sections.append(".TP\n\\fB45\\fR\nSchema validation error.")
|
|
141
|
+
sections.append(".TP\n\\fB46\\fR\nApproval denied or timed out.")
|
|
142
|
+
sections.append(".TP\n\\fB47\\fR\nConfiguration error.")
|
|
143
|
+
sections.append(".TP\n\\fB48\\fR\nSchema circular reference.")
|
|
144
|
+
sections.append(".TP\n\\fB77\\fR\nACL denied.")
|
|
145
|
+
sections.append(".TP\n\\fB130\\fR\nExecution cancelled (SIGINT).")
|
|
146
|
+
|
|
147
|
+
sections.append(".SH SEE ALSO")
|
|
148
|
+
sections.append("\\fBapcore-cli\\fR(1), \\fBapcore-cli-list\\fR(1), \\fBapcore-cli-describe\\fR(1)")
|
|
149
|
+
|
|
150
|
+
return "\n".join(sections)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def register_shell_commands(cli: click.Group) -> None:
|
|
154
|
+
"""Register completion and man commands."""
|
|
155
|
+
|
|
156
|
+
@cli.command("completion")
|
|
157
|
+
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
|
|
158
|
+
def completion_cmd(shell: str) -> None:
|
|
159
|
+
"""Generate shell completion script."""
|
|
160
|
+
generators = {
|
|
161
|
+
"bash": _generate_bash_completion,
|
|
162
|
+
"zsh": _generate_zsh_completion,
|
|
163
|
+
"fish": _generate_fish_completion,
|
|
164
|
+
}
|
|
165
|
+
click.echo(generators[shell]())
|
|
166
|
+
|
|
167
|
+
@cli.command("man")
|
|
168
|
+
@click.argument("command")
|
|
169
|
+
@click.pass_context
|
|
170
|
+
def man_cmd(ctx: click.Context, command: str) -> None:
|
|
171
|
+
"""Generate man page for a command."""
|
|
172
|
+
parent = ctx.parent
|
|
173
|
+
if parent is None:
|
|
174
|
+
click.echo(f"Error: Unknown command '{command}'.", err=True)
|
|
175
|
+
sys.exit(2)
|
|
176
|
+
|
|
177
|
+
parent_group = parent.command
|
|
178
|
+
cmd = parent_group.commands.get(command) if isinstance(parent_group, click.Group) else None
|
|
179
|
+
|
|
180
|
+
if cmd is None and command not in ("exec", "list", "describe", "completion", "man"):
|
|
181
|
+
click.echo(f"Error: Unknown command '{command}'.", err=True)
|
|
182
|
+
sys.exit(2)
|
|
183
|
+
|
|
184
|
+
roff = _generate_man_page(command, cmd, cli)
|
|
185
|
+
click.echo(roff)
|