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.
- solwyn_cli/__init__.py +3 -0
- solwyn_cli/_vendor/__init__.py +1 -0
- solwyn_cli/_vendor/validation.py +21 -0
- solwyn_cli/_vendor/wire.py +185 -0
- solwyn_cli/artifacts/__init__.py +727 -0
- solwyn_cli/artifacts/_atomic.py +79 -0
- solwyn_cli/auth.py +252 -0
- solwyn_cli/client.py +279 -0
- solwyn_cli/commands/__init__.py +1 -0
- solwyn_cli/commands/_shared.py +49 -0
- solwyn_cli/commands/agent_context.py +19 -0
- solwyn_cli/commands/budget.py +976 -0
- solwyn_cli/commands/config.py +110 -0
- solwyn_cli/commands/costs.py +186 -0
- solwyn_cli/commands/init.py +659 -0
- solwyn_cli/commands/keys.py +386 -0
- solwyn_cli/commands/login.py +205 -0
- solwyn_cli/commands/mcp.py +28 -0
- solwyn_cli/commands/projects.py +449 -0
- solwyn_cli/commands/status.py +173 -0
- solwyn_cli/commands/whoami.py +170 -0
- solwyn_cli/config.py +209 -0
- solwyn_cli/errors.py +114 -0
- solwyn_cli/exit_codes.py +56 -0
- solwyn_cli/help.py +19 -0
- solwyn_cli/main.py +135 -0
- solwyn_cli/mcp/__init__.py +5 -0
- solwyn_cli/mcp/server.py +728 -0
- solwyn_cli/output.py +263 -0
- solwyn_cli/runtime.py +115 -0
- solwyn_cli/spec/__init__.py +1 -0
- solwyn_cli/spec/openapi_snapshot.json +12357 -0
- solwyn_cli-0.1.0.dist-info/METADATA +60 -0
- solwyn_cli-0.1.0.dist-info/RECORD +37 -0
- solwyn_cli-0.1.0.dist-info/WHEEL +4 -0
- solwyn_cli-0.1.0.dist-info/entry_points.txt +3 -0
- solwyn_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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)
|