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,170 @@
|
|
|
1
|
+
"""Current identity command."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
|
7
|
+
|
|
8
|
+
from solwyn_cli.auth import (
|
|
9
|
+
AuthError,
|
|
10
|
+
JwtCredential,
|
|
11
|
+
ProjectKeyCredential,
|
|
12
|
+
resolve_credential,
|
|
13
|
+
)
|
|
14
|
+
from solwyn_cli.client import SolwynClient
|
|
15
|
+
from solwyn_cli.commands._shared import exit_with_error
|
|
16
|
+
from solwyn_cli.errors import ApiError
|
|
17
|
+
from solwyn_cli.exit_codes import ErrorSemantic
|
|
18
|
+
from solwyn_cli.help import help_contract
|
|
19
|
+
from solwyn_cli.output import OutputError, TableData
|
|
20
|
+
from solwyn_cli.runtime import command_context, output_engine
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
WHOAMI_HELP = help_contract(
|
|
24
|
+
endpoint=("local key identity or GET /api/v1/account/me and GET /api/v1/projects"),
|
|
25
|
+
auth="any",
|
|
26
|
+
exits="0 success; 1 generic/API; 2 usage; 3 auth",
|
|
27
|
+
schema="solwyn.whoami.v1",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Identity(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra="ignore")
|
|
33
|
+
|
|
34
|
+
user_id: str
|
|
35
|
+
email: str
|
|
36
|
+
display_name: str
|
|
37
|
+
subscription_tier: Literal["free", "founder", "team", "scale"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Project(BaseModel):
|
|
41
|
+
model_config = ConfigDict(extra="ignore")
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
name: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _WhoamiData(BaseModel):
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
auth_type: Literal["jwt", "project_key"]
|
|
51
|
+
auth_source: Literal["keyring", "environment"]
|
|
52
|
+
configured_project: str | None
|
|
53
|
+
user: _Identity | None
|
|
54
|
+
projects: list[_Project]
|
|
55
|
+
hint: str | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_PROJECTS = TypeAdapter(list[_Project])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _key_data(credential: ProjectKeyCredential, project: str | None) -> _WhoamiData:
|
|
62
|
+
return _WhoamiData(
|
|
63
|
+
auth_type="project_key",
|
|
64
|
+
auth_source=credential.source,
|
|
65
|
+
configured_project=project,
|
|
66
|
+
user=None,
|
|
67
|
+
projects=[],
|
|
68
|
+
hint=(
|
|
69
|
+
None
|
|
70
|
+
if project is not None
|
|
71
|
+
else "Choose a project with --project, SOLWYN_PROJECT_ID, or config."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _jwt_data(context: typer.Context, credential: JwtCredential) -> _WhoamiData:
|
|
77
|
+
runtime = command_context(context)
|
|
78
|
+
try:
|
|
79
|
+
with SolwynClient(
|
|
80
|
+
api_url=runtime.api_url,
|
|
81
|
+
credential=credential,
|
|
82
|
+
) as client:
|
|
83
|
+
identity_response = client.request("GET", "/api/v1/account/me")
|
|
84
|
+
projects_response = client.request("GET", "/api/v1/projects")
|
|
85
|
+
identity = _Identity.model_validate(identity_response.json())
|
|
86
|
+
projects = _PROJECTS.validate_python(projects_response.json())
|
|
87
|
+
except ApiError as error:
|
|
88
|
+
exit_with_error(context, error)
|
|
89
|
+
except AuthError as error:
|
|
90
|
+
exit_with_error(
|
|
91
|
+
context,
|
|
92
|
+
OutputError(code="authentication_failed", message=str(error)),
|
|
93
|
+
semantic=ErrorSemantic.AUTH,
|
|
94
|
+
)
|
|
95
|
+
except (ValidationError, ValueError, TypeError):
|
|
96
|
+
exit_with_error(
|
|
97
|
+
context,
|
|
98
|
+
OutputError(
|
|
99
|
+
code="invalid_identity_response",
|
|
100
|
+
message="The identity response was invalid.",
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
return _WhoamiData(
|
|
104
|
+
auth_type="jwt",
|
|
105
|
+
auth_source="keyring",
|
|
106
|
+
configured_project=None,
|
|
107
|
+
user=identity,
|
|
108
|
+
projects=projects,
|
|
109
|
+
hint=None,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _table(data: _WhoamiData) -> TableData:
|
|
114
|
+
rows: list[tuple[str, str]] = [
|
|
115
|
+
("Authentication", data.auth_type),
|
|
116
|
+
("Source", data.auth_source),
|
|
117
|
+
]
|
|
118
|
+
if data.auth_type == "project_key":
|
|
119
|
+
rows.append(("Configured project", data.configured_project or "not configured"))
|
|
120
|
+
if data.hint is not None:
|
|
121
|
+
rows.append(("Hint", data.hint))
|
|
122
|
+
elif data.user is not None:
|
|
123
|
+
rows.extend(
|
|
124
|
+
[
|
|
125
|
+
("User", data.user.display_name),
|
|
126
|
+
("Email", data.user.email),
|
|
127
|
+
("Tier", data.user.subscription_tier),
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
rows.extend(
|
|
131
|
+
("Project", f"{project.id} ({project.name})") for project in data.projects
|
|
132
|
+
)
|
|
133
|
+
return TableData(
|
|
134
|
+
columns=("Field", "Value"),
|
|
135
|
+
rows=tuple(rows),
|
|
136
|
+
right_align=("Value",),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def whoami(context: typer.Context) -> None:
|
|
141
|
+
"""Show the current Solwyn identity and project context."""
|
|
142
|
+
try:
|
|
143
|
+
credential = resolve_credential()
|
|
144
|
+
except AuthError as error:
|
|
145
|
+
exit_with_error(
|
|
146
|
+
context,
|
|
147
|
+
OutputError(code="authentication_failed", message=str(error)),
|
|
148
|
+
semantic=ErrorSemantic.AUTH,
|
|
149
|
+
)
|
|
150
|
+
if credential is None:
|
|
151
|
+
exit_with_error(
|
|
152
|
+
context,
|
|
153
|
+
OutputError(
|
|
154
|
+
code="authentication_required",
|
|
155
|
+
message="Authentication is required.",
|
|
156
|
+
hint="Set SOLWYN_API_KEY or run solwyn login.",
|
|
157
|
+
),
|
|
158
|
+
semantic=ErrorSemantic.AUTH,
|
|
159
|
+
)
|
|
160
|
+
runtime = command_context(context)
|
|
161
|
+
data = (
|
|
162
|
+
_key_data(credential, runtime.project)
|
|
163
|
+
if isinstance(credential, ProjectKeyCredential)
|
|
164
|
+
else _jwt_data(context, credential)
|
|
165
|
+
)
|
|
166
|
+
output_engine(context).write_success(
|
|
167
|
+
schema="solwyn.whoami.v1",
|
|
168
|
+
data=data,
|
|
169
|
+
table=_table(data),
|
|
170
|
+
)
|
solwyn_cli/config.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Allowlisted, non-secret local configuration for the Solwyn CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import tomllib
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import BinaryIO, Iterator, Literal
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
import tomli_w
|
|
13
|
+
from platformdirs import user_config_dir
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
|
|
15
|
+
|
|
16
|
+
if os.name == "nt":
|
|
17
|
+
import msvcrt
|
|
18
|
+
else:
|
|
19
|
+
import fcntl
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigLoadError(ValueError):
|
|
23
|
+
"""Configuration could not be safely loaded."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigWriteError(OSError):
|
|
27
|
+
"""Configuration could not be safely persisted."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Config(BaseModel):
|
|
31
|
+
"""Persisted CLI settings; credentials are intentionally not modeled."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
|
|
34
|
+
|
|
35
|
+
api_url: str = "https://api.solwyn.ai"
|
|
36
|
+
default_project: str | None = None
|
|
37
|
+
format: Literal["table", "json"] = "table"
|
|
38
|
+
|
|
39
|
+
@field_validator("api_url")
|
|
40
|
+
@classmethod
|
|
41
|
+
def validate_api_url(cls, value: str) -> str:
|
|
42
|
+
"""Require a credential-free absolute HTTP(S) API base URL."""
|
|
43
|
+
try:
|
|
44
|
+
parsed = urlsplit(value)
|
|
45
|
+
parsed.port
|
|
46
|
+
except ValueError:
|
|
47
|
+
raise ValueError("api_url must be a safe absolute HTTP(S) URL") from None
|
|
48
|
+
|
|
49
|
+
authority = parsed.netloc.rsplit("@", maxsplit=1)[-1]
|
|
50
|
+
if (
|
|
51
|
+
value != value.strip()
|
|
52
|
+
or "\\" in value
|
|
53
|
+
or any(character.isspace() for character in value)
|
|
54
|
+
or parsed.scheme not in {"http", "https"}
|
|
55
|
+
or parsed.hostname is None
|
|
56
|
+
or parsed.username is not None
|
|
57
|
+
or parsed.password is not None
|
|
58
|
+
or "?" in value
|
|
59
|
+
or "#" in value
|
|
60
|
+
or authority.endswith(":")
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("api_url must be a safe absolute HTTP(S) URL")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
CONFIG_KEYS = frozenset(Config.model_fields)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_config_path() -> Path:
|
|
70
|
+
"""Return the platform-native path to the user's local configuration."""
|
|
71
|
+
return Path(user_config_dir("solwyn")) / "config.toml"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config() -> Config:
|
|
75
|
+
"""Load allowlisted settings, returning defaults when no file exists."""
|
|
76
|
+
path = get_config_path()
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return Config()
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
with path.open("rb") as config_file:
|
|
82
|
+
values = tomllib.load(config_file)
|
|
83
|
+
return Config.model_validate(values)
|
|
84
|
+
except (
|
|
85
|
+
OSError,
|
|
86
|
+
UnicodeError,
|
|
87
|
+
tomllib.TOMLDecodeError,
|
|
88
|
+
ValidationError,
|
|
89
|
+
TypeError,
|
|
90
|
+
) as exc:
|
|
91
|
+
raise ConfigLoadError(f"Could not load configuration at {path}.") from exc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _acquire_lock(lock_file: BinaryIO) -> None:
|
|
95
|
+
"""Acquire an exclusive advisory lock using the host platform API."""
|
|
96
|
+
if os.name == "nt":
|
|
97
|
+
lock_file.seek(0, os.SEEK_END)
|
|
98
|
+
if lock_file.tell() == 0:
|
|
99
|
+
lock_file.write(b"\0")
|
|
100
|
+
lock_file.flush()
|
|
101
|
+
lock_file.seek(0)
|
|
102
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
|
103
|
+
else:
|
|
104
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _release_lock(lock_file: BinaryIO) -> None:
|
|
108
|
+
"""Release a lock acquired by :func:`_acquire_lock`."""
|
|
109
|
+
if os.name == "nt":
|
|
110
|
+
lock_file.seek(0)
|
|
111
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
112
|
+
else:
|
|
113
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@contextmanager
|
|
117
|
+
def _config_lock(path: Path) -> Iterator[None]:
|
|
118
|
+
"""Hold the cross-process lock associated with a configuration path."""
|
|
119
|
+
lock_path = path.with_suffix(f"{path.suffix}.lock")
|
|
120
|
+
try:
|
|
121
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
lock_file = lock_path.open("a+b")
|
|
123
|
+
try:
|
|
124
|
+
_acquire_lock(lock_file)
|
|
125
|
+
except OSError:
|
|
126
|
+
lock_file.close()
|
|
127
|
+
raise
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
raise ConfigWriteError(f"Could not lock configuration at {path}.") from exc
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
yield
|
|
133
|
+
finally:
|
|
134
|
+
try:
|
|
135
|
+
_release_lock(lock_file)
|
|
136
|
+
except OSError as exc:
|
|
137
|
+
raise ConfigWriteError(
|
|
138
|
+
f"Could not unlock configuration at {path}."
|
|
139
|
+
) from exc
|
|
140
|
+
finally:
|
|
141
|
+
lock_file.close()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _fsync_parent_directory(path: Path) -> None:
|
|
145
|
+
"""Durably record an atomic replace on POSIX; Windows has no directory fsync."""
|
|
146
|
+
if os.name == "nt":
|
|
147
|
+
return
|
|
148
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
149
|
+
directory_fd = os.open(path.parent, flags)
|
|
150
|
+
try:
|
|
151
|
+
os.fsync(directory_fd)
|
|
152
|
+
finally:
|
|
153
|
+
os.close(directory_fd)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def save_config(config: Config, *, omit_default_api_url: bool = False) -> None:
|
|
157
|
+
"""Atomically persist only modeled, non-null settings."""
|
|
158
|
+
path = get_config_path()
|
|
159
|
+
values = config.model_dump(include=CONFIG_KEYS, exclude_none=True)
|
|
160
|
+
if omit_default_api_url and config.api_url == Config().api_url:
|
|
161
|
+
values.pop("api_url", None)
|
|
162
|
+
content = tomli_w.dumps(values)
|
|
163
|
+
temporary_path: Path | None = None
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
with tempfile.NamedTemporaryFile(
|
|
168
|
+
mode="w",
|
|
169
|
+
encoding="utf-8",
|
|
170
|
+
dir=path.parent,
|
|
171
|
+
prefix=f".{path.name}.",
|
|
172
|
+
suffix=".tmp",
|
|
173
|
+
delete=False,
|
|
174
|
+
) as temporary_file:
|
|
175
|
+
temporary_path = Path(temporary_file.name)
|
|
176
|
+
temporary_file.write(content)
|
|
177
|
+
temporary_file.flush()
|
|
178
|
+
os.fsync(temporary_file.fileno())
|
|
179
|
+
os.replace(temporary_path, path)
|
|
180
|
+
_fsync_parent_directory(path)
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
raise ConfigWriteError(f"Could not write configuration at {path}.") from exc
|
|
183
|
+
finally:
|
|
184
|
+
if temporary_path is not None:
|
|
185
|
+
temporary_path.unlink(missing_ok=True)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def update_config_value(key: str, value: str) -> Config:
|
|
189
|
+
"""Atomically read, validate, and update one allowlisted setting."""
|
|
190
|
+
return update_config_values({key: value})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def update_config_values(
|
|
194
|
+
values: Mapping[str, object], *, omit_default_api_url: bool = False
|
|
195
|
+
) -> Config:
|
|
196
|
+
"""Atomically merge and persist multiple allowlisted settings under one lock."""
|
|
197
|
+
invalid_keys = set(values).difference(CONFIG_KEYS)
|
|
198
|
+
if invalid_keys:
|
|
199
|
+
invalid_key = sorted(invalid_keys)[0]
|
|
200
|
+
raise ValueError(f"Invalid configuration key: {invalid_key}")
|
|
201
|
+
|
|
202
|
+
path = get_config_path()
|
|
203
|
+
with _config_lock(path):
|
|
204
|
+
current = load_config()
|
|
205
|
+
updated = Config.model_validate(
|
|
206
|
+
{**current.model_dump(include=CONFIG_KEYS), **values}
|
|
207
|
+
)
|
|
208
|
+
save_config(updated, omit_default_api_url=omit_default_api_url)
|
|
209
|
+
return updated
|
solwyn_cli/errors.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Safe typed errors for HTTP and API failures."""
|
|
2
|
+
|
|
3
|
+
import unicodedata
|
|
4
|
+
from typing import Any, TypeAlias
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RawDetail: TypeAlias = str | dict[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiError(RuntimeError):
|
|
14
|
+
"""A structured request failure whose repr does not expose raw detail."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
status_code: int | None,
|
|
20
|
+
code: str,
|
|
21
|
+
message: str,
|
|
22
|
+
hint: str | None,
|
|
23
|
+
detail: RawDetail | None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.code = code
|
|
28
|
+
self.message = message
|
|
29
|
+
self.hint = hint
|
|
30
|
+
self.detail = detail
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe_cta_path(value: object) -> str | None:
|
|
34
|
+
if not isinstance(value, str):
|
|
35
|
+
return None
|
|
36
|
+
if not value.startswith("/") or value.startswith("//") or "\\" in value:
|
|
37
|
+
return None
|
|
38
|
+
if any(unicodedata.category(character).startswith("C") for character in value):
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
parsed = urlsplit(value)
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
if parsed.scheme or parsed.netloc or parsed.username or parsed.password:
|
|
45
|
+
return None
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _upgrade_hint(cta_path: str) -> str:
|
|
50
|
+
return f"Review upgrade options in the dashboard: {cta_path}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _quota_hint(cta_path: str) -> str:
|
|
54
|
+
return f"Review usage and plan limits in the dashboard: {cta_path}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _project_limit_hint(cta_path: str) -> str:
|
|
58
|
+
return f"Manage project limits in the dashboard: {cta_path}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_ENTITLEMENT_HINTS = {
|
|
62
|
+
"upgrade_required": _upgrade_hint,
|
|
63
|
+
"quota_exceeded": _quota_hint,
|
|
64
|
+
"project_limit_reached": _project_limit_hint,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _response_detail(response: httpx.Response) -> RawDetail | None:
|
|
69
|
+
try:
|
|
70
|
+
payload = response.json()
|
|
71
|
+
except (ValueError, TypeError):
|
|
72
|
+
return None
|
|
73
|
+
if not isinstance(payload, dict):
|
|
74
|
+
return None
|
|
75
|
+
detail = payload.get("detail")
|
|
76
|
+
if isinstance(detail, (str, dict)):
|
|
77
|
+
return detail
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def error_from_response(
|
|
82
|
+
response: httpx.Response,
|
|
83
|
+
*,
|
|
84
|
+
code_override: str | None = None,
|
|
85
|
+
) -> ApiError:
|
|
86
|
+
"""Turn an unsuccessful response into a safe structured error."""
|
|
87
|
+
detail = _response_detail(response)
|
|
88
|
+
fallback_message = f"Request failed with status {response.status_code}."
|
|
89
|
+
code = code_override or "http_error"
|
|
90
|
+
message = detail if isinstance(detail, str) else fallback_message
|
|
91
|
+
hint = None
|
|
92
|
+
|
|
93
|
+
if isinstance(detail, dict):
|
|
94
|
+
server_code = detail.get("code")
|
|
95
|
+
server_message = detail.get("message")
|
|
96
|
+
if code_override is None and isinstance(server_code, str) and server_code:
|
|
97
|
+
code = server_code
|
|
98
|
+
if isinstance(server_message, str) and server_message:
|
|
99
|
+
message = server_message
|
|
100
|
+
|
|
101
|
+
hint_builder = (
|
|
102
|
+
_ENTITLEMENT_HINTS.get(code) if response.status_code == 402 else None
|
|
103
|
+
)
|
|
104
|
+
cta_path = _safe_cta_path(detail.get("cta_path"))
|
|
105
|
+
if hint_builder is not None and cta_path is not None:
|
|
106
|
+
hint = hint_builder(cta_path)
|
|
107
|
+
|
|
108
|
+
return ApiError(
|
|
109
|
+
status_code=response.status_code,
|
|
110
|
+
code=code,
|
|
111
|
+
message=message,
|
|
112
|
+
hint=hint,
|
|
113
|
+
detail=detail,
|
|
114
|
+
)
|
solwyn_cli/exit_codes.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Stable process exit codes for the Solwyn CLI machine contract."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum, StrEnum
|
|
4
|
+
|
|
5
|
+
from solwyn_cli.auth import AuthError
|
|
6
|
+
from solwyn_cli.errors import ApiError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExitCode(IntEnum):
|
|
10
|
+
"""Permanently frozen exit meanings across all CLI major versions."""
|
|
11
|
+
|
|
12
|
+
SUCCESS = 0
|
|
13
|
+
GENERIC_ERROR = 1
|
|
14
|
+
USAGE_ERROR = 2
|
|
15
|
+
AUTH_ERROR = 3
|
|
16
|
+
BUDGET_DENIED = 4
|
|
17
|
+
NOT_FOUND = 5
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ErrorSemantic(StrEnum):
|
|
21
|
+
"""Explicit local semantics that cannot be inferred from HTTP status alone."""
|
|
22
|
+
|
|
23
|
+
GENERIC = "generic"
|
|
24
|
+
USAGE = "usage"
|
|
25
|
+
AUTH = "auth"
|
|
26
|
+
BUDGET_DENIED = "budget_denied"
|
|
27
|
+
NOT_FOUND = "not_found"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_SEMANTIC_EXIT_CODES = {
|
|
31
|
+
ErrorSemantic.GENERIC: ExitCode.GENERIC_ERROR,
|
|
32
|
+
ErrorSemantic.USAGE: ExitCode.USAGE_ERROR,
|
|
33
|
+
ErrorSemantic.AUTH: ExitCode.AUTH_ERROR,
|
|
34
|
+
ErrorSemantic.BUDGET_DENIED: ExitCode.BUDGET_DENIED,
|
|
35
|
+
ErrorSemantic.NOT_FOUND: ExitCode.NOT_FOUND,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def exit_code_for_error(
|
|
40
|
+
error: BaseException | None,
|
|
41
|
+
*,
|
|
42
|
+
semantic: ErrorSemantic | None = None,
|
|
43
|
+
) -> ExitCode:
|
|
44
|
+
"""Map typed failures without interfering with Click's native usage handling."""
|
|
45
|
+
if semantic is not None:
|
|
46
|
+
return _SEMANTIC_EXIT_CODES[semantic]
|
|
47
|
+
if error is None:
|
|
48
|
+
return ExitCode.SUCCESS
|
|
49
|
+
if isinstance(error, AuthError):
|
|
50
|
+
return ExitCode.AUTH_ERROR
|
|
51
|
+
if isinstance(error, ApiError):
|
|
52
|
+
if error.status_code == 401:
|
|
53
|
+
return ExitCode.AUTH_ERROR
|
|
54
|
+
if error.status_code == 404:
|
|
55
|
+
return ExitCode.NOT_FOUND
|
|
56
|
+
return ExitCode.GENERIC_ERROR
|
solwyn_cli/help.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Stable, machine-legible CLI help contract formatting."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def help_contract(
|
|
5
|
+
*,
|
|
6
|
+
endpoint: str,
|
|
7
|
+
auth: str,
|
|
8
|
+
exits: str,
|
|
9
|
+
schema: str,
|
|
10
|
+
guidance: tuple[str, ...] = (),
|
|
11
|
+
) -> str:
|
|
12
|
+
"""Render one semantic help contract without terminal-specific formatting."""
|
|
13
|
+
contract = (
|
|
14
|
+
f"Endpoint: {endpoint}",
|
|
15
|
+
f"Auth: {auth}",
|
|
16
|
+
f"Exit codes: {exits}",
|
|
17
|
+
f"JSON schema: {schema}",
|
|
18
|
+
)
|
|
19
|
+
return "\n".join((*contract, *guidance))
|
solwyn_cli/main.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Command-line entrypoint for Solwyn."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from solwyn_cli import __version__
|
|
9
|
+
from solwyn_cli.commands.agent_context import AGENT_CONTEXT_HELP, agent_context
|
|
10
|
+
from solwyn_cli.commands.budget import app as budget_app
|
|
11
|
+
from solwyn_cli.commands.config import app as config_app
|
|
12
|
+
from solwyn_cli.commands.costs import COSTS_HELP, costs
|
|
13
|
+
from solwyn_cli.commands.init import INIT_HELP, init
|
|
14
|
+
from solwyn_cli.commands.login import LOGIN_HELP, LOGOUT_HELP, login, logout
|
|
15
|
+
from solwyn_cli.commands.mcp import MCP_HELP, McpLaunchOptions, mcp
|
|
16
|
+
from solwyn_cli.commands.keys import app as keys_app
|
|
17
|
+
from solwyn_cli.commands.projects import app as projects_app
|
|
18
|
+
from solwyn_cli.commands.status import STATUS_HELP, status
|
|
19
|
+
from solwyn_cli.commands.whoami import WHOAMI_HELP, whoami
|
|
20
|
+
from solwyn_cli.config import ConfigLoadError
|
|
21
|
+
from solwyn_cli.help import help_contract
|
|
22
|
+
from solwyn_cli.output import OutputError, OutputUsageError
|
|
23
|
+
from solwyn_cli.runtime import (
|
|
24
|
+
bootstrap_output_engine,
|
|
25
|
+
resolve_command_context,
|
|
26
|
+
validate_output_intent,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
add_completion=False,
|
|
31
|
+
invoke_without_command=True,
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
epilog=help_contract(
|
|
34
|
+
endpoint="composite command router",
|
|
35
|
+
auth="varies by command",
|
|
36
|
+
exits="0 success; 2 usage",
|
|
37
|
+
schema="varies by command",
|
|
38
|
+
guidance=(
|
|
39
|
+
"Agents: run solwyn agent-context for the compact capability contract.",
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
app.add_typer(budget_app, name="budget")
|
|
44
|
+
app.add_typer(config_app, name="config")
|
|
45
|
+
app.add_typer(keys_app, name="keys")
|
|
46
|
+
app.add_typer(projects_app, name="projects")
|
|
47
|
+
app.command("agent-context", epilog=AGENT_CONTEXT_HELP)(agent_context)
|
|
48
|
+
app.command("costs", epilog=COSTS_HELP)(costs)
|
|
49
|
+
app.command("init", epilog=INIT_HELP)(init)
|
|
50
|
+
app.command("login", epilog=LOGIN_HELP)(login)
|
|
51
|
+
app.command("logout", epilog=LOGOUT_HELP)(logout)
|
|
52
|
+
app.command("mcp", epilog=MCP_HELP)(mcp)
|
|
53
|
+
app.command("status", epilog=STATUS_HELP)(status)
|
|
54
|
+
app.command("whoami", epilog=WHOAMI_HELP)(whoami)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.callback()
|
|
58
|
+
def main(
|
|
59
|
+
context: typer.Context,
|
|
60
|
+
version: Annotated[
|
|
61
|
+
bool,
|
|
62
|
+
typer.Option(
|
|
63
|
+
"--version", help="Show the installed version and exit.", is_eager=True
|
|
64
|
+
),
|
|
65
|
+
] = False,
|
|
66
|
+
project: Annotated[
|
|
67
|
+
str | None,
|
|
68
|
+
typer.Option("--project", "-p", help="Project ID for this invocation."),
|
|
69
|
+
] = None,
|
|
70
|
+
api_url: Annotated[
|
|
71
|
+
str | None,
|
|
72
|
+
typer.Option("--api-url", help="Solwyn API base URL."),
|
|
73
|
+
] = None,
|
|
74
|
+
output_format: Annotated[
|
|
75
|
+
str | None,
|
|
76
|
+
typer.Option("--format", help="Output format: table or json."),
|
|
77
|
+
] = None,
|
|
78
|
+
json_output: Annotated[
|
|
79
|
+
bool,
|
|
80
|
+
typer.Option("--json", help="Emit schema-bearing JSON output."),
|
|
81
|
+
] = False,
|
|
82
|
+
no_color: Annotated[
|
|
83
|
+
bool,
|
|
84
|
+
typer.Option("--no-color", help="Disable terminal color."),
|
|
85
|
+
] = False,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Work with Solwyn from the command line."""
|
|
88
|
+
if version:
|
|
89
|
+
typer.echo(f"solwyn {__version__}")
|
|
90
|
+
raise typer.Exit()
|
|
91
|
+
if context.invoked_subcommand == "mcp":
|
|
92
|
+
context.obj = McpLaunchOptions(api_url=api_url, project_id=project)
|
|
93
|
+
return
|
|
94
|
+
if context.invoked_subcommand in {"agent-context", "config"}:
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
validate_output_intent(
|
|
98
|
+
output_format=output_format,
|
|
99
|
+
json_flag=json_output,
|
|
100
|
+
)
|
|
101
|
+
except OutputUsageError:
|
|
102
|
+
raise typer.BadParameter(
|
|
103
|
+
"Invalid or conflicting output options.", param_hint="format"
|
|
104
|
+
) from None
|
|
105
|
+
try:
|
|
106
|
+
context.obj = resolve_command_context(
|
|
107
|
+
api_url=api_url,
|
|
108
|
+
project=project,
|
|
109
|
+
output_format=output_format,
|
|
110
|
+
json_flag=json_output,
|
|
111
|
+
no_color=no_color,
|
|
112
|
+
)
|
|
113
|
+
except OutputUsageError:
|
|
114
|
+
raise typer.BadParameter(
|
|
115
|
+
"Invalid or conflicting output options.", param_hint="format"
|
|
116
|
+
) from None
|
|
117
|
+
except ValidationError:
|
|
118
|
+
raise typer.BadParameter("Invalid API URL.", param_hint="api-url") from None
|
|
119
|
+
except ConfigLoadError:
|
|
120
|
+
if context.invoked_subcommand == "init":
|
|
121
|
+
bootstrap_output_engine(
|
|
122
|
+
output_format=output_format,
|
|
123
|
+
json_flag=json_output,
|
|
124
|
+
no_color=no_color,
|
|
125
|
+
).write_error(
|
|
126
|
+
OutputError(
|
|
127
|
+
code="config_load_failed",
|
|
128
|
+
message="The existing configuration could not be loaded.",
|
|
129
|
+
hint="Repair or remove the configuration file, then retry init.",
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
raise typer.Exit(code=1) from None
|
|
133
|
+
raise typer.BadParameter(
|
|
134
|
+
"Could not load configuration.", param_hint="config"
|
|
135
|
+
) from None
|