apizit-cli 0.2.0b1__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.
apizit_cli/context.py ADDED
@@ -0,0 +1,155 @@
1
+ """Non-secret machine and project preferences; credentials live in the OS vault."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from urllib.parse import urlsplit
10
+
11
+ from apizit_cli.errors import CliError, EXIT_USAGE
12
+
13
+ DEFAULT_API_ORIGIN = "https://jxo6vbfwy0.execute-api.eu-central-1.amazonaws.com/v1"
14
+
15
+
16
+ def normalize_origin(value: str) -> str:
17
+ parsed = urlsplit(value)
18
+ if (
19
+ not parsed.hostname
20
+ or parsed.username
21
+ or parsed.password
22
+ or parsed.query
23
+ or parsed.fragment
24
+ or "\\" in value
25
+ or any(ord(char) < 33 for char in value)
26
+ or (
27
+ parsed.scheme != "https"
28
+ and not (
29
+ parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
30
+ )
31
+ )
32
+ ):
33
+ raise CliError(
34
+ "APIZIT endpoint must be HTTPS (HTTP is allowed only on loopback).",
35
+ exit_code=EXIT_USAGE,
36
+ )
37
+ try:
38
+ parsed.port
39
+ except ValueError:
40
+ raise CliError("Invalid APIZIT endpoint port.", exit_code=EXIT_USAGE) from None
41
+ return value.rstrip("/")
42
+
43
+
44
+ def home_directory() -> Path:
45
+ override = os.environ.get("APIZIT_CONFIG_HOME")
46
+ return Path(override).expanduser().resolve() if override else Path.home() / ".apizit"
47
+
48
+
49
+ def read_json(path: Path) -> dict:
50
+ assert_safe_path(path)
51
+ if not path.exists():
52
+ return {}
53
+ try:
54
+ if path.is_symlink() or path.stat().st_size > 64 * 1024:
55
+ raise ValueError()
56
+ value = json.loads(path.read_text(encoding="utf-8"))
57
+ if not isinstance(value, dict):
58
+ raise ValueError()
59
+ return value
60
+ except (OSError, ValueError):
61
+ raise CliError(
62
+ f"Cannot read APIZIT context at {path}.", code="CONTEXT_INVALID", exit_code=EXIT_USAGE
63
+ ) from None
64
+
65
+
66
+ def write_json(path: Path, value: dict) -> None:
67
+ assert_safe_path(path)
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ if path.is_symlink():
70
+ raise CliError("Refusing to replace a symbolic context file.", exit_code=EXIT_USAGE)
71
+ descriptor, temporary = tempfile.mkstemp(prefix=".apizit-", dir=path.parent)
72
+ try:
73
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
74
+ json.dump(value, stream, indent=2)
75
+ stream.write("\n")
76
+ os.replace(temporary, path)
77
+ finally:
78
+ if os.path.exists(temporary):
79
+ os.unlink(temporary)
80
+
81
+
82
+ def assert_safe_path(path: Path) -> None:
83
+ for part in (path, *path.parents):
84
+ if part.is_symlink() or part.is_junction():
85
+ raise CliError(
86
+ "APIZIT context paths must not contain symbolic links or junctions.",
87
+ code="CONTEXT_INVALID",
88
+ exit_code=EXIT_USAGE,
89
+ )
90
+
91
+
92
+ class ClientContext:
93
+ def __init__(self, *, workspace_id=None, root=None, endpoint=None):
94
+ self.root = Path(root or Path.cwd()).resolve()
95
+ self.path = home_directory() / "context.json"
96
+ self.project_path = self.root / ".apizit" / "project.json"
97
+ self.preferences = read_json(self.path)
98
+ self.project = read_json(self.project_path)
99
+ self.origin = normalize_origin(
100
+ endpoint or os.getenv("APIZIT_API_URL") or DEFAULT_API_ORIGIN
101
+ )
102
+ saved = self.preferences.get(self.origin, {})
103
+ if not isinstance(saved, dict):
104
+ raise CliError("Invalid saved endpoint context.", exit_code=EXIT_USAGE)
105
+ self.workspace_id = (
106
+ workspace_id
107
+ or os.getenv("APIZIT_WORKSPACE_ID")
108
+ or self.project.get("workspace_id")
109
+ or saved.get("workspace_id")
110
+ )
111
+ if self.project and self.project.get("origin") != self.origin:
112
+ raise CliError(
113
+ "This project is linked to another APIZIT endpoint.",
114
+ code="PROJECT_ENDPOINT_MISMATCH",
115
+ exit_code=EXIT_USAGE,
116
+ )
117
+ if self.project.get("api_id") and self.project.get("workspace_id") != self.workspace_id:
118
+ raise CliError(
119
+ "The linked API belongs to another workspace. Use an unlinked project context.",
120
+ code="PROJECT_WORKSPACE_MISMATCH",
121
+ exit_code=EXIT_USAGE,
122
+ )
123
+
124
+ def require_workspace(self):
125
+ import re
126
+
127
+ if not isinstance(self.workspace_id, str) or not re.fullmatch(
128
+ r"ws_[A-Za-z0-9_-]{1,128}", self.workspace_id
129
+ ):
130
+ raise CliError(
131
+ "Choose a workspace with apizit workspace use <id> or --workspace.",
132
+ code="WORKSPACE_REQUIRED",
133
+ exit_code=EXIT_USAGE,
134
+ )
135
+ return self.workspace_id
136
+
137
+ def select_workspace(self, workspace_id):
138
+ self.preferences[self.origin] = {"workspace_id": workspace_id}
139
+ write_json(self.path, self.preferences)
140
+ self.workspace_id = workspace_id
141
+
142
+ def bind_api(self, api_id):
143
+ ignore = self.root / ".gitignore"
144
+ assert_safe_path(ignore)
145
+ assert_safe_path(self.project_path)
146
+ previous = ignore.read_text(encoding="utf-8") if ignore.exists() else ""
147
+ write_json(
148
+ self.project_path,
149
+ {"origin": self.origin, "workspace_id": self.require_workspace(), "api_id": api_id},
150
+ )
151
+ if "/.apizit/" not in previous.splitlines():
152
+ with ignore.open("a", encoding="utf-8") as stream:
153
+ stream.write(
154
+ ("\n" if previous and not previous.endswith("\n") else "") + "/.apizit/\n"
155
+ )
@@ -0,0 +1,170 @@
1
+ """Bounded OS-vault entries for providers with long renewal credentials."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import secrets
10
+ import time
11
+ from contextlib import contextmanager
12
+
13
+ from apizit_cli.context import assert_safe_path
14
+ from apizit_cli.errors import CliError, EXIT_AUTH
15
+
16
+ PART_SIZE = 1000 # Windows generic credentials allow 2560 bytes, stored as UTF-16 by keyring.
17
+ MAX_PARTS = 32
18
+
19
+
20
+ @contextmanager
21
+ def session_lock(service, *, directory=None, timeout=30):
22
+ """Serialize renewal/revocation across processes; the lock contains no credentials."""
23
+ directory = directory or Path.home() / ".apizit" / "session-locks"
24
+ assert_safe_path(directory)
25
+ directory.mkdir(parents=True, exist_ok=True)
26
+ path = directory / (hashlib.sha256(service.encode()).hexdigest() + ".lock")
27
+ assert_safe_path(path)
28
+ with path.open("a+b") as stream:
29
+ if stream.tell() == 0:
30
+ stream.write(b"0")
31
+ stream.flush()
32
+ deadline = time.monotonic() + timeout
33
+ while True:
34
+ stream.seek(0)
35
+ try:
36
+ if os.name == "nt":
37
+ import msvcrt
38
+
39
+ msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
40
+ else:
41
+ import fcntl
42
+
43
+ fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
44
+ break
45
+ except OSError:
46
+ if time.monotonic() >= deadline:
47
+ raise CliError(
48
+ "Another APIZIT process is renewing or clearing the session. Retry shortly.",
49
+ code="SESSION_BUSY",
50
+ exit_code=EXIT_AUTH,
51
+ ) from None
52
+ time.sleep(0.05)
53
+ try:
54
+ yield
55
+ finally:
56
+ stream.seek(0)
57
+ if os.name == "nt":
58
+ msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
59
+ else:
60
+ fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
61
+
62
+
63
+ class CredentialStore:
64
+ def __init__(self, vault, service):
65
+ self.vault = vault
66
+ self.service = service
67
+
68
+ def _parts(self, value):
69
+ if not value:
70
+ return []
71
+ record = json.loads(value)
72
+ if "vault_format" not in record:
73
+ return []
74
+ if (
75
+ record.get("vault_format") != 1
76
+ or not re.fullmatch(r"[0-9a-f]{32}", record.get("generation", ""))
77
+ or type(record.get("parts")) is not int
78
+ or not 1 <= record["parts"] <= MAX_PARTS
79
+ or not re.fullmatch(r"[0-9a-f]{64}", record.get("sha256", ""))
80
+ ):
81
+ raise ValueError("Invalid secure session manifest")
82
+ return [f"{self.service}:part:{record['generation']}:{i}" for i in range(record["parts"])]
83
+
84
+ def read(self):
85
+ self._recover()
86
+ for _ in range(3):
87
+ value = self.vault.get_password(self.service, "session")
88
+ parts = self._parts(value)
89
+ if not parts:
90
+ return value
91
+ fragments = [self.vault.get_password(part, "session") for part in parts]
92
+ if any(not isinstance(part, str) or len(part) > PART_SIZE for part in fragments):
93
+ if self.vault.get_password(self.service, "session") != value:
94
+ continue # A concurrent renewal replaced and cleaned the old generation.
95
+ raise ValueError("Incomplete secure session")
96
+ result = "".join(fragments)
97
+ if not hmac.compare_digest(
98
+ hashlib.sha256(result.encode()).hexdigest(), json.loads(value)["sha256"]
99
+ ):
100
+ raise ValueError("Invalid secure session integrity")
101
+ return result
102
+ raise ValueError("Secure session changed while reading")
103
+
104
+ def _clear_parts(self, parts):
105
+ for part in parts:
106
+ if self.vault.get_password(part, "session") is not None:
107
+ self.vault.delete_password(part, "session")
108
+
109
+ def _recover(self):
110
+ pending = self.vault.get_password(self.service + ":pending", "session")
111
+ if pending is None:
112
+ return
113
+ journal = json.loads(pending)
114
+ if not re.fullmatch(r"[0-9a-f]{64}", journal.get("target_sha256", "")):
115
+ raise ValueError("Invalid secure session recovery record")
116
+ previous = self._parts(journal.get("previous"))
117
+ next_parts = self._parts(journal.get("next"))
118
+ current = self.vault.get_password(self.service, "session") or ""
119
+ committed = hashlib.sha256(current.encode()).hexdigest() == journal["target_sha256"]
120
+ self._clear_parts(previous if committed else next_parts)
121
+ self.vault.delete_password(self.service + ":pending", "session")
122
+
123
+ def write(self, value):
124
+ if not value.isascii() or len(value) > PART_SIZE * MAX_PARTS:
125
+ raise ValueError("Secure session exceeds its storage limit")
126
+ self._recover()
127
+ previous = self.vault.get_password(self.service, "session")
128
+ old_parts = self._parts(previous)
129
+ new_parts = []
130
+ manifest = value
131
+ if len(value) > PART_SIZE:
132
+ record = {
133
+ "vault_format": 1,
134
+ "generation": secrets.token_hex(16),
135
+ "parts": (len(value) + PART_SIZE - 1) // PART_SIZE,
136
+ "sha256": hashlib.sha256(value.encode()).hexdigest(),
137
+ }
138
+ manifest = json.dumps(record)
139
+ new_parts = self._parts(manifest)
140
+ if new_parts or old_parts:
141
+ # A metadata-only journal in the same vault permits cleanup after process death.
142
+ self.vault.set_password(
143
+ self.service + ":pending",
144
+ "session",
145
+ json.dumps(
146
+ {
147
+ "previous": previous if old_parts else None,
148
+ "next": manifest if new_parts else None,
149
+ "target_sha256": hashlib.sha256(manifest.encode()).hexdigest(),
150
+ }
151
+ ),
152
+ )
153
+ try:
154
+ for index, part in enumerate(new_parts):
155
+ self.vault.set_password(
156
+ part, "session", value[index * PART_SIZE : (index + 1) * PART_SIZE]
157
+ )
158
+ # Publish the complete generation only after every fragment is in the OS vault.
159
+ self.vault.set_password(self.service, "session", manifest)
160
+ except Exception:
161
+ self._recover()
162
+ raise
163
+ self._recover()
164
+
165
+ def delete(self):
166
+ self._recover()
167
+ value = self.vault.get_password(self.service, "session")
168
+ self._clear_parts(self._parts(value))
169
+ if value is not None:
170
+ self.vault.delete_password(self.service, "session")
apizit_cli/errors.py ADDED
@@ -0,0 +1,19 @@
1
+ """Stable CLI exit and error contract."""
2
+
3
+ EXIT_OK = 0
4
+ EXIT_FAILED = 1
5
+ EXIT_USAGE = 2
6
+ EXIT_AUTH = 3
7
+ EXIT_DENIED = 4
8
+ EXIT_CONFLICT = 5
9
+ EXIT_NETWORK = 6
10
+ EXIT_TIMEOUT = 7
11
+ EXIT_INTERRUPTED = 130
12
+
13
+
14
+ class CliError(Exception):
15
+ def __init__(self, message, *, code="CLI_ERROR", exit_code=EXIT_FAILED, details=None):
16
+ super().__init__(message)
17
+ self.code = code
18
+ self.exit_code = exit_code
19
+ self.details = details or {}
apizit_cli/http.py ADDED
@@ -0,0 +1,162 @@
1
+ """One workspace-fenced HTTP transport for every CLI operation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from urllib.parse import quote
7
+
8
+ import requests
9
+
10
+ from apizit_cli.authentication import NativeSession
11
+ from apizit_cli.errors import (
12
+ CliError,
13
+ EXIT_AUTH,
14
+ EXIT_CONFLICT,
15
+ EXIT_DENIED,
16
+ EXIT_NETWORK,
17
+ EXIT_TIMEOUT,
18
+ EXIT_USAGE,
19
+ )
20
+
21
+
22
+ def identifier(value):
23
+ if (
24
+ not isinstance(value, str)
25
+ or not value
26
+ or len(value) > 256
27
+ or any(ord(c) < 33 for c in value)
28
+ ):
29
+ raise CliError("Invalid resource identifier.", code="INVALID_IDENTIFIER", exit_code=2)
30
+ return quote(value, safe="")
31
+
32
+
33
+ class PlatformClient:
34
+ def __init__(self, context, *, session=None, authentication=None):
35
+ self.context = context
36
+ self.http = session or requests.Session()
37
+ self.http.trust_env = False
38
+ self.auth = authentication or NativeSession(context.origin, http=self.http)
39
+ self.request_id = None
40
+
41
+ def request(
42
+ self,
43
+ method,
44
+ path,
45
+ *,
46
+ body=None,
47
+ query=None,
48
+ workspace=True,
49
+ authenticated=True,
50
+ idempotency_key=None,
51
+ timeout=30,
52
+ ):
53
+ if not path.startswith("/") or path.startswith("//") or "?" in path or "#" in path:
54
+ raise ValueError("Expected an API-relative path without a query.")
55
+ headers = {"Accept": "application/json"}
56
+ deadline = time.monotonic() + timeout
57
+ if workspace:
58
+ headers["X-Workspace-Id"] = self.context.require_workspace()
59
+ if authenticated:
60
+ headers["Authorization"] = "Bearer " + self.auth.token()
61
+ if idempotency_key:
62
+ headers["Idempotency-Key"] = idempotency_key
63
+ attempts = 3 if method == "GET" or (method == "POST" and idempotency_key) else 1
64
+ renewed = False
65
+ for attempt in range(attempts + 1):
66
+ remaining = deadline - time.monotonic()
67
+ if remaining <= 0:
68
+ raise CliError(
69
+ "Local request time expired. A remote operation may still be running.",
70
+ code="REQUEST_TIMEOUT",
71
+ exit_code=EXIT_TIMEOUT,
72
+ )
73
+ try:
74
+ response = self.http.request(
75
+ method,
76
+ self.context.origin + path,
77
+ headers=headers,
78
+ json=body,
79
+ params=query,
80
+ timeout=(min(5, remaining / 2), remaining / 2),
81
+ allow_redirects=False,
82
+ )
83
+ except (requests.ConnectionError, requests.Timeout):
84
+ if attempt + 1 < attempts:
85
+ time.sleep(min(0.2 * (attempt + 1), max(0, deadline - time.monotonic())))
86
+ continue
87
+ raise CliError(
88
+ "Could not reach APIZIT. A submitted operation may still be running; reuse its idempotency key.",
89
+ code="NETWORK_UNAVAILABLE",
90
+ exit_code=EXIT_NETWORK,
91
+ ) from None
92
+ except requests.RequestException:
93
+ raise CliError(
94
+ "Could not reach APIZIT.", code="NETWORK_UNAVAILABLE", exit_code=EXIT_NETWORK
95
+ ) from None
96
+ self.request_id = response.headers.get("X-Request-Id")
97
+ if (
98
+ response.status_code == 401
99
+ and authenticated
100
+ and not renewed
101
+ and not self.auth.injected_token
102
+ ):
103
+ headers["Authorization"] = "Bearer " + self.auth.token(force_refresh=True)
104
+ renewed = True
105
+ continue
106
+ if response.status_code in {502, 503, 504} and attempt + 1 < attempts:
107
+ time.sleep(min(0.2 * (attempt + 1), max(0, deadline - time.monotonic())))
108
+ continue
109
+ if response.status_code == 204:
110
+ return {}
111
+ try:
112
+ payload = response.json()
113
+ if not isinstance(payload, dict):
114
+ raise ValueError()
115
+ except (ValueError, TypeError):
116
+ raise CliError(
117
+ "APIZIT returned an invalid response.",
118
+ code="INVALID_RESPONSE",
119
+ exit_code=EXIT_NETWORK,
120
+ details={"http_status": response.status_code, "request_id": self.request_id},
121
+ ) from None
122
+ if not 200 <= response.status_code < 300:
123
+ message = payload.get("error")
124
+ exit_code = {
125
+ 400: EXIT_USAGE,
126
+ 401: EXIT_AUTH,
127
+ 402: EXIT_DENIED,
128
+ 403: EXIT_DENIED,
129
+ 409: EXIT_CONFLICT,
130
+ 423: EXIT_DENIED,
131
+ 422: EXIT_USAGE,
132
+ 429: EXIT_DENIED,
133
+ 428: EXIT_USAGE,
134
+ }.get(response.status_code, EXIT_NETWORK if response.status_code >= 500 else 1)
135
+ raise CliError(
136
+ message if isinstance(message, str) else "APIZIT rejected the request.",
137
+ code=payload.get("code") or "HTTP_ERROR",
138
+ exit_code=exit_code,
139
+ details={
140
+ "http_status": response.status_code,
141
+ "request_id": self.request_id,
142
+ "retry_after": response.headers.get("Retry-After"),
143
+ **{
144
+ key: payload[key]
145
+ for key in (
146
+ "profile_required",
147
+ "required_fields",
148
+ "action",
149
+ "job_id",
150
+ "scan_id",
151
+ "deployment_id",
152
+ )
153
+ if key in payload
154
+ },
155
+ },
156
+ )
157
+ return payload
158
+ raise CliError(
159
+ "APIZIT authentication could not be renewed.",
160
+ code="LOGIN_REQUIRED",
161
+ exit_code=EXIT_AUTH,
162
+ )
apizit_cli/main.py ADDED
@@ -0,0 +1,164 @@
1
+ """Public executable: HTTP client only, with a stable machine output contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import sys
9
+
10
+ from apizit_cli import __version__
11
+ from apizit_cli.commands import COMMANDS, GLOBAL_OPTIONS
12
+ from apizit_cli.configuration import ConfigurationError
13
+ from apizit_cli.errors import CliError, EXIT_INTERRUPTED, EXIT_USAGE
14
+
15
+
16
+ class Parser(argparse.ArgumentParser):
17
+ def error(self, message):
18
+ # argparse can include argument values in its diagnostics. Keep those out of logs.
19
+ raise CliError(
20
+ "Invalid or missing command arguments. Run apizit <command> --help.",
21
+ code="INVALID_ARGUMENTS",
22
+ exit_code=EXIT_USAGE,
23
+ )
24
+
25
+
26
+ def positive_float(value):
27
+ number = float(value)
28
+ if not math.isfinite(number) or number <= 0:
29
+ raise ValueError("Expected a positive finite number.")
30
+ return number
31
+
32
+
33
+ def add_options(parser, options, *, global_options=False):
34
+ for option in options:
35
+ kwargs = {"help": option.help}
36
+ if option.action:
37
+ kwargs["action"] = option.action
38
+ elif option.value_type != "str":
39
+ kwargs["type"] = {"int": int, "positive_float": positive_float}[option.value_type]
40
+ if option.choices:
41
+ kwargs["choices"] = option.choices
42
+ if option.nargs:
43
+ kwargs["nargs"] = option.nargs
44
+ if option.flags[0].startswith("-"):
45
+ kwargs["default"] = argparse.SUPPRESS if global_options else option.default
46
+ parser.add_argument(*option.flags, **kwargs)
47
+
48
+
49
+ def build_parser():
50
+ common = Parser(add_help=False)
51
+ add_options(common, GLOBAL_OPTIONS, global_options=True)
52
+ parser = Parser(
53
+ prog="apizit",
54
+ description="Launch Python as a managed public API. APIZIT development beta.",
55
+ parents=[common],
56
+ allow_abbrev=False,
57
+ )
58
+ parser.add_argument("--version", action="version", version=f"apizit {__version__}")
59
+ root = parser.add_subparsers(dest="command", required=True)
60
+ groups = {}
61
+ for command in COMMANDS:
62
+ words = command.path.split()
63
+ parent = root
64
+ if len(words) == 2:
65
+ if words[0] not in groups:
66
+ group = root.add_parser(
67
+ words[0],
68
+ parents=[common],
69
+ allow_abbrev=False,
70
+ help=f"Manage {words[0]} resources.",
71
+ )
72
+ groups[words[0]] = group.add_subparsers(dest="action", required=True)
73
+ parent = groups[words[0]]
74
+ leaf = parent.add_parser(
75
+ words[-1],
76
+ parents=[common],
77
+ allow_abbrev=False,
78
+ help=command.summary,
79
+ description=command.summary,
80
+ )
81
+ leaf.set_defaults(command_path=command.path)
82
+ add_options(leaf, command.options)
83
+ return parser
84
+
85
+
86
+ def envelope(*, command, data=None, error=None, runner=None):
87
+ return {
88
+ "schema_version": 1,
89
+ "ok": error is None,
90
+ "command": command,
91
+ "data": data,
92
+ "error": (
93
+ {"code": error.code, "message": str(error), "details": error.details} if error else None
94
+ ),
95
+ "meta": {
96
+ "cli_version": __version__,
97
+ "workspace_id": runner.context.workspace_id if runner else None,
98
+ "request_id": runner.client.request_id if runner else None,
99
+ },
100
+ }
101
+
102
+
103
+ def main(argv=None):
104
+ argv = list(sys.argv[1:] if argv is None else argv)
105
+ json_mode = "--json" in argv
106
+ runner = None
107
+ command = None
108
+ exit_code = 0
109
+ try:
110
+ args = build_parser().parse_args(argv)
111
+ json_mode = bool(getattr(args, "json", False))
112
+ command = args.command_path
113
+ from apizit_cli.operations import CommandRunner
114
+
115
+ runner = CommandRunner(args)
116
+ data = runner.run()
117
+ result = envelope(command=command, data=data, runner=runner)
118
+ except KeyboardInterrupt:
119
+ exit_code = EXIT_INTERRUPTED
120
+ error = CliError(
121
+ "Local observation interrupted. A remote operation may still be running.",
122
+ code="LOCAL_INTERRUPTED",
123
+ exit_code=EXIT_INTERRUPTED,
124
+ details=runner.tracking if runner else {},
125
+ )
126
+ result = envelope(command=command, error=error, runner=runner)
127
+ except CliError as error:
128
+ exit_code = error.exit_code
129
+ if runner:
130
+ error.details = {**runner.tracking, **error.details}
131
+ result = envelope(command=command, error=error, runner=runner)
132
+ except ConfigurationError as cause:
133
+ exit_code = EXIT_USAGE
134
+ error = CliError(str(cause), code="CONFIGURATION_INVALID", exit_code=EXIT_USAGE)
135
+ result = envelope(command=command, error=error, runner=runner)
136
+ except (OSError, UnicodeError):
137
+ exit_code = EXIT_USAGE
138
+ error = CliError(
139
+ "A local file or device could not be read or written.",
140
+ code="LOCAL_IO_ERROR",
141
+ exit_code=EXIT_USAGE,
142
+ details=runner.tracking if runner else {},
143
+ )
144
+ result = envelope(command=command, error=error, runner=runner)
145
+ except Exception:
146
+ exit_code = 1
147
+ error = CliError(
148
+ "The client could not complete this command. Use the request ID and client version when reporting it.",
149
+ code="CLIENT_UNEXPECTED_ERROR",
150
+ details=runner.tracking if runner else {},
151
+ )
152
+ result = envelope(command=command, error=error, runner=runner)
153
+ if json_mode:
154
+ print(json.dumps(result, ensure_ascii=True, separators=(",", ":")))
155
+ elif result["ok"]:
156
+ # JSON quoting prevents untrusted API names or logs from executing terminal escapes.
157
+ print(json.dumps(result["data"], ensure_ascii=True, indent=2))
158
+ else:
159
+ print(json.dumps(result["error"], ensure_ascii=True, indent=2), file=sys.stderr)
160
+ return exit_code
161
+
162
+
163
+ if __name__ == "__main__":
164
+ raise SystemExit(main())