solwyn-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.
@@ -0,0 +1,79 @@
1
+ """Descriptor-relative atomic rename operations for Tier-1 POSIX platforms."""
2
+
3
+ import ctypes
4
+ import errno
5
+ import os
6
+ import sys
7
+
8
+
9
+ _LIBC = ctypes.CDLL(None, use_errno=True)
10
+ _LINUX_NOREPLACE = 1
11
+ _LINUX_EXCHANGE = 2
12
+ _MACOS_SWAP = 0x00000002
13
+ _MACOS_EXCL = 0x00000004
14
+
15
+
16
+ def _call(
17
+ function_name: str, source: str, target: str, parent_fd: int, flag: int
18
+ ) -> None:
19
+ try:
20
+ function = getattr(_LIBC, function_name)
21
+ except AttributeError:
22
+ raise OSError(
23
+ errno.ENOTSUP,
24
+ f"Atomic rename operation {function_name} is unavailable.",
25
+ ) from None
26
+ function.argtypes = [
27
+ ctypes.c_int,
28
+ ctypes.c_char_p,
29
+ ctypes.c_int,
30
+ ctypes.c_char_p,
31
+ ctypes.c_uint,
32
+ ]
33
+ function.restype = ctypes.c_int
34
+ ctypes.set_errno(0)
35
+ result = function(
36
+ parent_fd,
37
+ os.fsencode(source),
38
+ parent_fd,
39
+ os.fsencode(target),
40
+ flag,
41
+ )
42
+ if result != 0:
43
+ error_number = ctypes.get_errno() or errno.EIO
44
+ raise OSError(error_number, os.strerror(error_number), target)
45
+
46
+
47
+ def _dispatch(source: str, target: str, parent_fd: int, *, exchange: bool) -> None:
48
+ if sys.platform == "linux":
49
+ _call(
50
+ "renameat2",
51
+ source,
52
+ target,
53
+ parent_fd,
54
+ _LINUX_EXCHANGE if exchange else _LINUX_NOREPLACE,
55
+ )
56
+ return
57
+ if sys.platform == "darwin":
58
+ _call(
59
+ "renameatx_np",
60
+ source,
61
+ target,
62
+ parent_fd,
63
+ _MACOS_SWAP if exchange else _MACOS_EXCL,
64
+ )
65
+ return
66
+ raise OSError(
67
+ errno.ENOTSUP,
68
+ f"Atomic artifact renames are unsupported on {sys.platform}.",
69
+ )
70
+
71
+
72
+ def atomic_exchange(source: str, target: str, parent_fd: int) -> None:
73
+ """Atomically swap two existing names without losing either inode."""
74
+ _dispatch(source, target, parent_fd, exchange=True)
75
+
76
+
77
+ def atomic_noreplace(source: str, target: str, parent_fd: int) -> None:
78
+ """Atomically rename source only when target does not exist."""
79
+ _dispatch(source, target, parent_fd, exchange=False)
solwyn_cli/auth.py ADDED
@@ -0,0 +1,252 @@
1
+ """Credential resolution and bounded JWT refresh for authenticated requests."""
2
+
3
+ import os
4
+ from collections.abc import Callable, Mapping
5
+ from typing import ClassVar, Literal, TypeAlias
6
+
7
+ import httpx
8
+ import keyring
9
+ from keyring.errors import NoKeyringError, PasswordDeleteError
10
+ from pydantic import (
11
+ BaseModel,
12
+ ConfigDict,
13
+ Field,
14
+ SecretStr,
15
+ ValidationError,
16
+ field_serializer,
17
+ field_validator,
18
+ )
19
+
20
+ from solwyn_cli._vendor.validation import validate_project_key_format
21
+ from solwyn_cli._vendor.wire import RefreshRequest
22
+
23
+ KEYRING_SERVICE = "solwyn-cli"
24
+ API_KEY_ENTRY = "api_key"
25
+ ACCESS_TOKEN_ENTRY = "access_token"
26
+ REFRESH_TOKEN_ENTRY = "refresh_token"
27
+ AUTH_EXIT_CODE = 3
28
+
29
+
30
+ class AuthError(RuntimeError):
31
+ """Authentication failed without exposing credential material."""
32
+
33
+ exit_code: ClassVar[int] = AUTH_EXIT_CODE
34
+
35
+
36
+ class CredentialStorageError(AuthError):
37
+ """The operating-system credential store could not be updated."""
38
+
39
+
40
+ class ProjectKeyCredential(BaseModel):
41
+ """A validated project API key with an explicitly retrievable secret."""
42
+
43
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
44
+
45
+ api_key: SecretStr
46
+ source: Literal["environment", "keyring"]
47
+
48
+ @field_validator("api_key")
49
+ @classmethod
50
+ def validate_api_key(cls, value: SecretStr) -> SecretStr:
51
+ """Keep direct model construction inside the project-key invariant."""
52
+ validate_project_key_format(value.get_secret_value())
53
+ return value
54
+
55
+ def bearer_value(self) -> str:
56
+ """Return the key for an Authorization header at the request boundary."""
57
+ return self.api_key.get_secret_value()
58
+
59
+
60
+ class JwtCredential(BaseModel):
61
+ """A complete access/refresh pair whose repr remains redacted."""
62
+
63
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
64
+
65
+ access_token: SecretStr = Field(min_length=1)
66
+ refresh_token: SecretStr = Field(min_length=1)
67
+
68
+ def bearer_value(self) -> str:
69
+ """Return the access token at the request boundary."""
70
+ return self.access_token.get_secret_value()
71
+
72
+ def refresh_value(self) -> str:
73
+ """Return the refresh token only for the refresh request boundary."""
74
+ return self.refresh_token.get_secret_value()
75
+
76
+
77
+ class _TokenResponse(BaseModel):
78
+ """Validated token rotation response from the auth API."""
79
+
80
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
81
+
82
+ access_token: SecretStr = Field(min_length=1)
83
+ refresh_token: SecretStr = Field(min_length=1)
84
+ token_type: Literal["bearer"] = "bearer"
85
+
86
+
87
+ class _RefreshRequest(RefreshRequest):
88
+ """Refresh request whose in-memory representation redacts its token."""
89
+
90
+ refresh_token: SecretStr
91
+
92
+ @field_serializer("refresh_token")
93
+ def serialize_refresh_token(self, value: SecretStr) -> str:
94
+ """Reveal the token only while constructing the authenticated POST body."""
95
+ return value.get_secret_value()
96
+
97
+
98
+ ResolvedCredential: TypeAlias = ProjectKeyCredential | JwtCredential
99
+ AuthenticatedRequest: TypeAlias = Callable[[ResolvedCredential], httpx.Response]
100
+ CredentialRotation: TypeAlias = Callable[[JwtCredential], None]
101
+
102
+
103
+ def _project_key_credential(
104
+ value: str, source: Literal["environment", "keyring"]
105
+ ) -> ProjectKeyCredential:
106
+ try:
107
+ validated = validate_project_key_format(value)
108
+ except ValueError:
109
+ raise AuthError("Configured project key is invalid.") from None
110
+ return ProjectKeyCredential(api_key=validated, source=source)
111
+
112
+
113
+ def _get_password(entry: str) -> tuple[bool, str | None]:
114
+ """Read one entry, reporting an unavailable backend as a safe outcome."""
115
+ try:
116
+ return True, keyring.get_password(KEYRING_SERVICE, entry)
117
+ except NoKeyringError:
118
+ return False, None
119
+ except Exception:
120
+ return False, None
121
+
122
+
123
+ def resolve_credential(
124
+ environ: Mapping[str, str] | None = None,
125
+ ) -> ResolvedCredential | None:
126
+ """Resolve credentials in the frozen env, API-key, JWT precedence order."""
127
+ environment = os.environ if environ is None else environ
128
+ if "SOLWYN_API_KEY" in environment:
129
+ return _project_key_credential(environment["SOLWYN_API_KEY"], "environment")
130
+
131
+ available, stored_key = _get_password(API_KEY_ENTRY)
132
+ if not available:
133
+ return None
134
+ if stored_key is not None:
135
+ return _project_key_credential(stored_key, "keyring")
136
+
137
+ available, access_token = _get_password(ACCESS_TOKEN_ENTRY)
138
+ if not available:
139
+ return None
140
+ available, refresh_token = _get_password(REFRESH_TOKEN_ENTRY)
141
+ if not available:
142
+ return None
143
+ if not access_token or not refresh_token:
144
+ return None
145
+ return JwtCredential(
146
+ access_token=access_token,
147
+ refresh_token=refresh_token,
148
+ )
149
+
150
+
151
+ def _set_password(entry: str, value: str) -> None:
152
+ try:
153
+ keyring.set_password(KEYRING_SERVICE, entry, value)
154
+ except NoKeyringError:
155
+ raise CredentialStorageError(
156
+ "Could not update credentials in the operating-system keychain."
157
+ ) from None
158
+ except Exception:
159
+ raise CredentialStorageError(
160
+ "Could not update credentials in the operating-system keychain."
161
+ ) from None
162
+
163
+
164
+ def _confirm_entry_absent(entry: str) -> bool:
165
+ available, retained_value = _get_password(entry)
166
+ return available and retained_value is None
167
+
168
+
169
+ def _delete_password(entry: str) -> bool:
170
+ try:
171
+ keyring.delete_password(KEYRING_SERVICE, entry)
172
+ except PasswordDeleteError:
173
+ return _confirm_entry_absent(entry)
174
+ except NoKeyringError:
175
+ return _confirm_entry_absent(entry)
176
+ except Exception:
177
+ return _confirm_entry_absent(entry)
178
+ return True
179
+
180
+
181
+ def store_project_key(project_key: str) -> ProjectKeyCredential:
182
+ """Validate and store a project key only in the OS keychain."""
183
+ credential = _project_key_credential(project_key, "keyring")
184
+ _set_password(API_KEY_ENTRY, credential.bearer_value())
185
+ return credential
186
+
187
+
188
+ def store_jwt_pair(access_token: str, refresh_token: str) -> JwtCredential:
189
+ """Store or rotate a complete JWT pair only in the OS keychain."""
190
+ try:
191
+ credential = JwtCredential(
192
+ access_token=access_token,
193
+ refresh_token=refresh_token,
194
+ )
195
+ except ValidationError:
196
+ raise CredentialStorageError("Received an invalid token pair.") from None
197
+ _set_password(REFRESH_TOKEN_ENTRY, credential.refresh_value())
198
+ _set_password(ACCESS_TOKEN_ENTRY, credential.bearer_value())
199
+ return credential
200
+
201
+
202
+ def clear_credentials() -> None:
203
+ """Remove every supported credential entry from the OS keychain."""
204
+ all_entries_cleared = True
205
+ for entry in (API_KEY_ENTRY, ACCESS_TOKEN_ENTRY, REFRESH_TOKEN_ENTRY):
206
+ if not _delete_password(entry):
207
+ all_entries_cleared = False
208
+ if not all_entries_cleared:
209
+ raise CredentialStorageError(
210
+ "Could not clear credentials from the operating-system keychain."
211
+ )
212
+
213
+
214
+ def request_with_refresh_retry(
215
+ *,
216
+ client: httpx.Client,
217
+ api_url: str,
218
+ credential: ResolvedCredential,
219
+ request: AuthenticatedRequest,
220
+ on_credential_rotated: CredentialRotation | None = None,
221
+ ) -> httpx.Response:
222
+ """Run a request and make at most one JWT refresh plus one retry on 401."""
223
+ response = request(credential)
224
+ if response.status_code != httpx.codes.UNAUTHORIZED:
225
+ return response
226
+ if not isinstance(credential, JwtCredential):
227
+ raise AuthError("Authentication failed. Check your credentials.")
228
+
229
+ refresh_request = _RefreshRequest(refresh_token=credential.refresh_value())
230
+ try:
231
+ refresh_response = client.post(
232
+ f"{api_url.rstrip('/')}/api/v1/auth/refresh",
233
+ json=refresh_request.model_dump(),
234
+ )
235
+ if not refresh_response.is_success:
236
+ raise AuthError("Authentication refresh failed. Log in again.")
237
+ token_response = _TokenResponse.model_validate(refresh_response.json())
238
+ except AuthError:
239
+ raise
240
+ except (httpx.HTTPError, ValueError, TypeError, ValidationError):
241
+ raise AuthError("Authentication refresh failed. Log in again.") from None
242
+
243
+ rotated = store_jwt_pair(
244
+ token_response.access_token.get_secret_value(),
245
+ token_response.refresh_token.get_secret_value(),
246
+ )
247
+ if on_credential_rotated is not None:
248
+ on_credential_rotated(rotated)
249
+ retry_response = request(rotated)
250
+ if retry_response.status_code == httpx.codes.UNAUTHORIZED:
251
+ raise AuthError("Authentication failed after refresh. Log in again.")
252
+ return retry_response
solwyn_cli/client.py ADDED
@@ -0,0 +1,279 @@
1
+ """Synchronous authenticated HTTP client with bounded retries."""
2
+
3
+ import time
4
+ from collections.abc import Callable, Mapping
5
+ from datetime import UTC, datetime
6
+ from email.utils import parsedate_to_datetime
7
+ from typing import Any, cast
8
+ from urllib.parse import unquote, urlsplit
9
+
10
+ import httpx
11
+
12
+ from solwyn_cli import __version__
13
+ from solwyn_cli.auth import (
14
+ AuthError,
15
+ JwtCredential,
16
+ ResolvedCredential,
17
+ request_with_refresh_retry,
18
+ resolve_credential,
19
+ )
20
+ from solwyn_cli.errors import ApiError, error_from_response
21
+
22
+
23
+ CONNECT_TIMEOUT_SECONDS = 5.0
24
+ READ_TIMEOUT_SECONDS = 30.0
25
+ WRITE_TIMEOUT_SECONDS = 30.0
26
+ POOL_TIMEOUT_SECONDS = 5.0
27
+ MAX_RETRY_AFTER_SECONDS = 10.0
28
+
29
+ _UNSET = object()
30
+
31
+
32
+ def _contains_dot_segment(path: str) -> bool:
33
+ decoded_path = path
34
+ while True:
35
+ normalized_path = decoded_path.replace("\\", "/")
36
+ if any(segment in {".", ".."} for segment in normalized_path.split("/")):
37
+ return True
38
+ next_path = unquote(decoded_path)
39
+ if next_path == decoded_path:
40
+ return False
41
+ decoded_path = next_path
42
+
43
+
44
+ def _retry_after_seconds(value: str | None, now: datetime) -> float:
45
+ if value is None:
46
+ return 0.0
47
+ try:
48
+ integer_delay = int(value.strip())
49
+ except ValueError:
50
+ try:
51
+ retry_at = parsedate_to_datetime(value)
52
+ if retry_at.tzinfo is None:
53
+ retry_at = retry_at.replace(tzinfo=UTC)
54
+ if now.tzinfo is None:
55
+ now = now.replace(tzinfo=UTC)
56
+ except (TypeError, ValueError, OverflowError):
57
+ return 0.0
58
+ delay = (retry_at - now).total_seconds()
59
+ return min(MAX_RETRY_AFTER_SECONDS, max(0.0, delay))
60
+ return float(min(int(MAX_RETRY_AFTER_SECONDS), max(0, integer_delay)))
61
+
62
+
63
+ class SolwynClient:
64
+ """A closeable synchronous client for authenticated Solwyn API requests."""
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ api_url: str,
70
+ credential: ResolvedCredential | None | object = _UNSET,
71
+ transport: httpx.BaseTransport | None = None,
72
+ sleeper: Callable[[float], object] = time.sleep,
73
+ clock: Callable[[], datetime] | None = None,
74
+ ) -> None:
75
+ normalized_api_url = f"{api_url.rstrip('/')}/"
76
+ self._api_url = normalized_api_url.rstrip("/")
77
+ self._credential = credential
78
+ self._sleeper = sleeper
79
+ self._clock = clock or (lambda: datetime.now(UTC))
80
+ self._client = httpx.Client(
81
+ base_url=normalized_api_url,
82
+ headers={"User-Agent": f"solwyn-cli/{__version__}"},
83
+ timeout=httpx.Timeout(
84
+ connect=CONNECT_TIMEOUT_SECONDS,
85
+ read=READ_TIMEOUT_SECONDS,
86
+ write=WRITE_TIMEOUT_SECONDS,
87
+ pool=POOL_TIMEOUT_SECONDS,
88
+ ),
89
+ transport=transport,
90
+ )
91
+
92
+ @property
93
+ def is_closed(self) -> bool:
94
+ """Report whether the underlying connection pool is closed."""
95
+ return self._client.is_closed
96
+
97
+ @property
98
+ def current_credential(self) -> ResolvedCredential:
99
+ """Return the credential currently retained after any JWT rotation."""
100
+ return self._resolve_credential()
101
+
102
+ def close(self) -> None:
103
+ """Close the underlying HTTP connection pool."""
104
+ self._client.close()
105
+
106
+ def __enter__(self) -> "SolwynClient":
107
+ return self
108
+
109
+ def __exit__(self, *args: object) -> None:
110
+ self.close()
111
+
112
+ def _resolve_credential(self) -> ResolvedCredential:
113
+ credential = (
114
+ resolve_credential()
115
+ if self._credential is _UNSET
116
+ else cast(ResolvedCredential | None, self._credential)
117
+ )
118
+ if credential is None:
119
+ raise AuthError("Authentication required. Configure credentials and retry.")
120
+ return credential
121
+
122
+ def _prepare_request(
123
+ self,
124
+ path: str,
125
+ kwargs: dict[str, Any],
126
+ ) -> tuple[str, httpx.Headers]:
127
+ """Validate a relative path and fixed-timeout request options."""
128
+ parsed_path = urlsplit(path)
129
+ if parsed_path.scheme or parsed_path.netloc:
130
+ raise ApiError(
131
+ status_code=None,
132
+ code="invalid_request",
133
+ message="API request paths must be relative.",
134
+ hint=None,
135
+ detail=None,
136
+ )
137
+ if _contains_dot_segment(parsed_path.path):
138
+ raise ApiError(
139
+ status_code=None,
140
+ code="invalid_request",
141
+ message="API request path is invalid.",
142
+ hint=None,
143
+ detail=None,
144
+ )
145
+ request_extensions = kwargs.get("extensions")
146
+ if "timeout" in kwargs or (
147
+ isinstance(request_extensions, Mapping) and "timeout" in request_extensions
148
+ ):
149
+ raise ApiError(
150
+ status_code=None,
151
+ code="invalid_request",
152
+ message="Request timeouts are fixed by the CLI.",
153
+ hint=None,
154
+ detail=None,
155
+ )
156
+ request_headers = httpx.Headers(kwargs.pop("headers", None))
157
+ request_headers["User-Agent"] = f"solwyn-cli/{__version__}"
158
+ return path.lstrip("/"), request_headers
159
+
160
+ def _send_with_rate_limit_retry(
161
+ self,
162
+ *,
163
+ method: str,
164
+ path: str,
165
+ headers: httpx.Headers,
166
+ kwargs: dict[str, Any],
167
+ retry_state: list[bool],
168
+ retry_rate_limit: bool = True,
169
+ ) -> httpx.Response:
170
+ """Send with the one shared bounded 429 retry budget."""
171
+ response = self._client.request(
172
+ method,
173
+ path,
174
+ headers=headers,
175
+ **kwargs,
176
+ )
177
+ if retry_rate_limit and response.status_code == httpx.codes.TOO_MANY_REQUESTS:
178
+ if retry_state[0]:
179
+ return response
180
+ retry_state[0] = True
181
+ delay = _retry_after_seconds(
182
+ response.headers.get("Retry-After"), self._clock()
183
+ )
184
+ self._sleeper(delay)
185
+ response = self._client.request(
186
+ method,
187
+ path,
188
+ headers=headers,
189
+ **kwargs,
190
+ )
191
+ return response
192
+
193
+ @staticmethod
194
+ def _raise_for_unsuccessful_response(response: httpx.Response) -> None:
195
+ if response.status_code == httpx.codes.TOO_MANY_REQUESTS:
196
+ raise error_from_response(response, code_override="rate_limited")
197
+ if not response.is_success:
198
+ raise error_from_response(response)
199
+
200
+ def request_unauthenticated(
201
+ self,
202
+ method: str,
203
+ path: str,
204
+ **kwargs: Any,
205
+ ) -> httpx.Response:
206
+ """Make a guarded request without resolving or sending credentials."""
207
+ relative_path, request_headers = self._prepare_request(path, kwargs)
208
+ try:
209
+ response = self._send_with_rate_limit_retry(
210
+ method=method,
211
+ path=relative_path,
212
+ headers=request_headers,
213
+ kwargs=kwargs,
214
+ retry_state=[False],
215
+ )
216
+ except httpx.HTTPError:
217
+ raise ApiError(
218
+ status_code=None,
219
+ code="transport_error",
220
+ message="Could not connect to the Solwyn API.",
221
+ hint=None,
222
+ detail=None,
223
+ ) from None
224
+ self._raise_for_unsuccessful_response(response)
225
+ return response
226
+
227
+ def request(
228
+ self,
229
+ method: str,
230
+ path: str,
231
+ *,
232
+ retry_rate_limit: bool = True,
233
+ retry_auth: bool = True,
234
+ **kwargs: Any,
235
+ ) -> httpx.Response:
236
+ """Make one logical request with bounded rate-limit and auth retries."""
237
+ relative_path, request_headers = self._prepare_request(path, kwargs)
238
+ credential = self._resolve_credential()
239
+ retry_state = [False]
240
+
241
+ def retain_rotated_credential(rotated: JwtCredential) -> None:
242
+ if self._credential is not _UNSET:
243
+ self._credential = rotated
244
+
245
+ def send(resolved: ResolvedCredential) -> httpx.Response:
246
+ headers = request_headers.copy()
247
+ headers["Authorization"] = f"Bearer {resolved.bearer_value()}"
248
+ return self._send_with_rate_limit_retry(
249
+ method=method,
250
+ path=relative_path,
251
+ headers=headers,
252
+ kwargs=kwargs,
253
+ retry_state=retry_state,
254
+ retry_rate_limit=retry_rate_limit,
255
+ )
256
+
257
+ try:
258
+ response = (
259
+ request_with_refresh_retry(
260
+ client=self._client,
261
+ api_url=self._api_url,
262
+ credential=credential,
263
+ request=send,
264
+ on_credential_rotated=retain_rotated_credential,
265
+ )
266
+ if retry_auth
267
+ else send(credential)
268
+ )
269
+ except httpx.HTTPError:
270
+ raise ApiError(
271
+ status_code=None,
272
+ code="transport_error",
273
+ message="Could not connect to the Solwyn API.",
274
+ hint=None,
275
+ detail=None,
276
+ ) from None
277
+
278
+ self._raise_for_unsuccessful_response(response)
279
+ return response
@@ -0,0 +1 @@
1
+ """Command groups exposed by the Solwyn CLI."""
@@ -0,0 +1,49 @@
1
+ """Shared command authentication and error dispatch."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ import typer
6
+
7
+ from solwyn_cli.auth import AuthError, ResolvedCredential, resolve_credential
8
+ from solwyn_cli.errors import ApiError
9
+ from solwyn_cli.exit_codes import ErrorSemantic, exit_code_for_error
10
+ from solwyn_cli.output import OutputError
11
+ from solwyn_cli.runtime import output_engine
12
+
13
+
14
+ def required_credential(
15
+ context: typer.Context,
16
+ *,
17
+ resolve: Callable[[], ResolvedCredential | None] = resolve_credential,
18
+ ) -> ResolvedCredential:
19
+ """Resolve one required credential with the stable command auth contract."""
20
+ try:
21
+ credential = resolve()
22
+ except AuthError as error:
23
+ exit_with_error(
24
+ context,
25
+ OutputError(code="authentication_failed", message=str(error)),
26
+ semantic=ErrorSemantic.AUTH,
27
+ )
28
+ if credential is None:
29
+ exit_with_error(
30
+ context,
31
+ OutputError(
32
+ code="authentication_required",
33
+ message="Authentication is required.",
34
+ hint="Set SOLWYN_API_KEY or run solwyn login.",
35
+ ),
36
+ semantic=ErrorSemantic.AUTH,
37
+ )
38
+ return credential
39
+
40
+
41
+ def exit_with_error(
42
+ context: typer.Context,
43
+ error: ApiError | OutputError,
44
+ *,
45
+ semantic: ErrorSemantic | None = None,
46
+ ) -> None:
47
+ """Render one safe error and stop with its stable process exit code."""
48
+ output_engine(context).write_error(error)
49
+ raise typer.Exit(code=int(exit_code_for_error(error, semantic=semantic)))
@@ -0,0 +1,19 @@
1
+ """Render deterministic agent-facing Solwyn capability guidance."""
2
+
3
+ import typer
4
+
5
+ from solwyn_cli.artifacts import ArtifactSurface, render_surface
6
+ from solwyn_cli.help import help_contract
7
+
8
+
9
+ AGENT_CONTEXT_HELP = help_contract(
10
+ endpoint="local capability artifact",
11
+ auth="local (none)",
12
+ exits="0 success; 2 usage",
13
+ schema="none (plain text capability document)",
14
+ )
15
+
16
+
17
+ def agent_context() -> None:
18
+ """Print safe Solwyn budget guidance for coding agents."""
19
+ typer.echo(render_surface(ArtifactSurface.AGENT_CONTEXT), nl=False)