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,386 @@
1
+ """Project key recovery commands."""
2
+
3
+ from typing import Annotated
4
+ from urllib.parse import quote
5
+
6
+ import typer
7
+ from pydantic import (
8
+ AwareDatetime,
9
+ BaseModel,
10
+ ConfigDict,
11
+ Field,
12
+ SecretStr,
13
+ ValidationError,
14
+ field_serializer,
15
+ field_validator,
16
+ model_validator,
17
+ )
18
+
19
+ from solwyn_cli._vendor.validation import validate_project_key_format
20
+ from solwyn_cli._vendor.wire import ProjectKeyRotateRequest
21
+ from solwyn_cli.auth import (
22
+ AuthError,
23
+ JwtCredential,
24
+ ProjectKeyCredential,
25
+ resolve_credential,
26
+ )
27
+ from solwyn_cli.client import SolwynClient
28
+ from solwyn_cli.commands._shared import exit_with_error, required_credential
29
+ from solwyn_cli.errors import ApiError
30
+ from solwyn_cli.exit_codes import ErrorSemantic
31
+ from solwyn_cli.help import help_contract
32
+ from solwyn_cli.output import OneTimeSecretPanel, OutputError, TableData
33
+ from solwyn_cli.runtime import command_context, output_engine
34
+
35
+
36
+ app = typer.Typer(
37
+ help="Recover from leaked or expired project keys.",
38
+ epilog=help_contract(
39
+ endpoint="composite router for project-key subcommands",
40
+ auth="JWT",
41
+ exits="0 success; 2 usage",
42
+ schema="varies by subcommand",
43
+ guidance=("Metadata help never reveals existing key material.",),
44
+ ),
45
+ )
46
+
47
+
48
+ class _ProjectKeyMetadata(BaseModel):
49
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
50
+
51
+ id: str = Field(min_length=1)
52
+ key_prefix: str = Field(pattern=r"^sk_proj_[a-f0-9]{16}$")
53
+ name: str = Field(min_length=1)
54
+ last_used_at: AwareDatetime | None
55
+ expires_at: AwareDatetime | None
56
+ rotation_grace_until: AwareDatetime | None
57
+ created_at: AwareDatetime
58
+
59
+
60
+ class _ProjectKeyRotateResponse(BaseModel):
61
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
62
+
63
+ id: str = Field(min_length=1)
64
+ key: SecretStr
65
+ key_prefix: str = Field(pattern=r"^sk_proj_[a-f0-9]{16}$")
66
+ rotation_grace_until: AwareDatetime
67
+
68
+ @field_validator("key")
69
+ @classmethod
70
+ def validate_key(cls, value: SecretStr) -> SecretStr:
71
+ validate_project_key_format(value.get_secret_value())
72
+ return value
73
+
74
+ @model_validator(mode="after")
75
+ def validate_key_prefix(self) -> "_ProjectKeyRotateResponse":
76
+ if self.key_prefix != self.key.get_secret_value()[:24]:
77
+ raise ValueError("Invalid project key prefix")
78
+ return self
79
+
80
+ @field_serializer("key")
81
+ def serialize_key(self, value: SecretStr) -> str:
82
+ return value.get_secret_value()
83
+
84
+
85
+ class _ProjectKeyRevokeData(BaseModel):
86
+ model_config = ConfigDict(extra="forbid", frozen=True)
87
+
88
+ project_id: str
89
+ key_id: str
90
+ revoked: bool = True
91
+
92
+
93
+ def _date_time(value: AwareDatetime | None) -> str:
94
+ if value is None:
95
+ return "-"
96
+ return value.isoformat().replace("+00:00", "Z")
97
+
98
+
99
+ def _metadata_table(data: _ProjectKeyMetadata) -> TableData:
100
+ return TableData(
101
+ columns=("Field", "Value"),
102
+ rows=(
103
+ ("ID", data.id),
104
+ ("Key prefix", data.key_prefix),
105
+ ("Name", data.name),
106
+ ("Last used", _date_time(data.last_used_at)),
107
+ ("Expires", _date_time(data.expires_at)),
108
+ ("Rotation grace until", _date_time(data.rotation_grace_until)),
109
+ ("Created", _date_time(data.created_at)),
110
+ ),
111
+ right_align=("Value",),
112
+ )
113
+
114
+
115
+ def _rotate_table(data: _ProjectKeyRotateResponse) -> TableData:
116
+ return TableData(
117
+ columns=("Field", "Value"),
118
+ rows=(
119
+ ("ID", data.id),
120
+ ("Key prefix", data.key_prefix),
121
+ ("Rotation grace until", _date_time(data.rotation_grace_until)),
122
+ ),
123
+ right_align=("Value",),
124
+ )
125
+
126
+
127
+ def _revoke_table(data: _ProjectKeyRevokeData) -> TableData:
128
+ return TableData(
129
+ columns=("Field", "Value"),
130
+ rows=(
131
+ ("Project", data.project_id),
132
+ ("Key ID", data.key_id),
133
+ ("Revoked", "true"),
134
+ ),
135
+ right_align=("Value",),
136
+ )
137
+
138
+
139
+ def _validated_identifier(
140
+ context: typer.Context,
141
+ value: str | None,
142
+ *,
143
+ label: str,
144
+ ) -> str:
145
+ if value is None or not value.strip():
146
+ hint = (
147
+ "Choose a project with --project, SOLWYN_PROJECT_ID, or "
148
+ "config default_project."
149
+ if label == "project"
150
+ else "Provide a nonblank key identifier with --key-id."
151
+ )
152
+ exit_with_error(
153
+ context,
154
+ OutputError(
155
+ code=f"{label}_required",
156
+ message=f"A {label} is required.",
157
+ hint=hint,
158
+ ),
159
+ semantic=ErrorSemantic.USAGE,
160
+ )
161
+ return value.strip()
162
+
163
+
164
+ def _jwt_credential(context: typer.Context, *, operation: str) -> JwtCredential:
165
+ credential = required_credential(context, resolve=resolve_credential)
166
+ if isinstance(credential, ProjectKeyCredential):
167
+ hint = (
168
+ f"Unset SOLWYN_API_KEY, run solwyn login, then retry keys {operation}."
169
+ if credential.source == "environment"
170
+ else (f"Run solwyn logout, then solwyn login, and retry keys {operation}.")
171
+ )
172
+ exit_with_error(
173
+ context,
174
+ OutputError(
175
+ code="authentication_required",
176
+ message="User login is required to manage project keys.",
177
+ hint=hint,
178
+ ),
179
+ semantic=ErrorSemantic.AUTH,
180
+ )
181
+ return credential
182
+
183
+
184
+ def _actionable_api_error(error: ApiError) -> ApiError:
185
+ hints = {
186
+ 404: "Verify the project or key identifier and retry.",
187
+ 409: "A key rotation may already be in progress; inspect the active key before retrying.",
188
+ 410: "The project key is inactive; inspect current key metadata before retrying.",
189
+ 429: "Retry later after the project-key rate limit resets.",
190
+ }
191
+ hint = error.hint or hints.get(error.status_code)
192
+ if hint == error.hint:
193
+ return error
194
+ return ApiError(
195
+ status_code=error.status_code,
196
+ code=error.code,
197
+ message=error.message,
198
+ hint=hint,
199
+ detail=error.detail,
200
+ )
201
+
202
+
203
+ def _invalid_response(context: typer.Context) -> None:
204
+ exit_with_error(
205
+ context,
206
+ OutputError(
207
+ code="invalid_keys_response",
208
+ message="The project key response was invalid.",
209
+ ),
210
+ )
211
+
212
+
213
+ def _validate_metadata_response(payload: object) -> _ProjectKeyMetadata:
214
+ """Reject raw key material before metadata validation can inspect it."""
215
+ if not isinstance(payload, dict) or "key" in payload:
216
+ raise ValueError("Invalid project key metadata")
217
+ return _ProjectKeyMetadata.model_validate(payload)
218
+
219
+
220
+ @app.command(
221
+ "show",
222
+ epilog=help_contract(
223
+ endpoint="GET /api/v1/projects/{project}/key",
224
+ auth="JWT",
225
+ exits="0 success; 1 generic/API; 2 usage; 3 auth; 5 not found",
226
+ schema="solwyn.keys-show.v1",
227
+ guidance=("Shows metadata only and never reveals existing key material.",),
228
+ ),
229
+ )
230
+ def show_key(context: typer.Context) -> None:
231
+ """Show active project-key metadata without revealing key material."""
232
+ runtime = command_context(context)
233
+ project_id = _validated_identifier(context, runtime.project, label="project")
234
+ credential = _jwt_credential(context, operation="show")
235
+ path = quote(project_id, safe="")
236
+ try:
237
+ with SolwynClient(api_url=runtime.api_url, credential=credential) as client:
238
+ response = client.request("GET", f"/api/v1/projects/{path}/key")
239
+ if response.status_code != 200:
240
+ raise ValueError("Key metadata did not return 200")
241
+ data = _validate_metadata_response(response.json())
242
+ except ApiError as error:
243
+ exit_with_error(context, _actionable_api_error(error))
244
+ except AuthError as error:
245
+ exit_with_error(
246
+ context,
247
+ OutputError(code="authentication_failed", message=str(error)),
248
+ semantic=ErrorSemantic.AUTH,
249
+ )
250
+ except (ValidationError, ValueError, TypeError):
251
+ _invalid_response(context)
252
+
253
+ output_engine(context).write_success(
254
+ schema="solwyn.keys-show.v1",
255
+ data=data,
256
+ table=_metadata_table(data),
257
+ )
258
+
259
+
260
+ @app.command(
261
+ "rotate",
262
+ epilog=help_contract(
263
+ endpoint="POST /api/v1/projects/{project}/key/rotate",
264
+ auth="JWT",
265
+ exits="0 success; 1 generic/API; 2 usage; 3 auth; 5 not found",
266
+ schema="solwyn.keys-rotate.v1",
267
+ guidance=(
268
+ "The replacement key is shown exactly once; store it in the OS keychain.",
269
+ ),
270
+ ),
271
+ )
272
+ def rotate_key(
273
+ context: typer.Context,
274
+ grace_seconds: Annotated[
275
+ int | None,
276
+ typer.Option(
277
+ "--grace-seconds",
278
+ help="Seconds the old key remains valid (0 through 604800).",
279
+ ),
280
+ ] = None,
281
+ ) -> None:
282
+ """Rotate the active key and reveal its replacement exactly once."""
283
+ runtime = command_context(context)
284
+ project_id = _validated_identifier(context, runtime.project, label="project")
285
+ try:
286
+ request = ProjectKeyRotateRequest(grace_seconds=grace_seconds)
287
+ except ValidationError:
288
+ exit_with_error(
289
+ context,
290
+ OutputError(
291
+ code="invalid_key_rotation_request",
292
+ message="The key rotation request is invalid.",
293
+ hint="Use --grace-seconds with a value from 0 through 604800.",
294
+ ),
295
+ semantic=ErrorSemantic.USAGE,
296
+ )
297
+ credential = _jwt_credential(context, operation="rotate")
298
+ path = quote(project_id, safe="")
299
+ try:
300
+ with SolwynClient(api_url=runtime.api_url, credential=credential) as client:
301
+ response = client.request(
302
+ "POST",
303
+ f"/api/v1/projects/{path}/key/rotate",
304
+ json=request.model_dump(mode="json"),
305
+ retry_rate_limit=False,
306
+ retry_auth=False,
307
+ )
308
+ if response.status_code != 200:
309
+ raise ValueError("Key rotation did not return 200")
310
+ data = _ProjectKeyRotateResponse.model_validate(response.json())
311
+ except ApiError as error:
312
+ exit_with_error(context, _actionable_api_error(error))
313
+ except AuthError as error:
314
+ exit_with_error(
315
+ context,
316
+ OutputError(code="authentication_failed", message=str(error)),
317
+ semantic=ErrorSemantic.AUTH,
318
+ )
319
+ except (ValidationError, ValueError, TypeError):
320
+ _invalid_response(context)
321
+
322
+ output_engine(context).write_success(
323
+ schema="solwyn.keys-rotate.v1",
324
+ data=data,
325
+ table=_rotate_table(data),
326
+ one_time_secret=OneTimeSecretPanel(
327
+ title="One-time replacement project key",
328
+ value=data.key,
329
+ ),
330
+ )
331
+
332
+
333
+ @app.command(
334
+ "revoke",
335
+ epilog=help_contract(
336
+ endpoint="DELETE /api/v1/projects/{project}/key/{key_id}",
337
+ auth="JWT",
338
+ exits="0 success; 1 generic/API; 2 usage; 3 auth; 5 not found",
339
+ schema="solwyn.keys-revoke.v1 (CLI output after bodyless 204)",
340
+ guidance=("Accepts only a key identifier; never pass key material.",),
341
+ ),
342
+ )
343
+ def revoke_key(
344
+ context: typer.Context,
345
+ key_id: Annotated[
346
+ str,
347
+ typer.Option("--key-id", help="Identifier of the key to hard-revoke."),
348
+ ],
349
+ ) -> None:
350
+ """Hard-revoke a project key without minting a replacement."""
351
+ runtime = command_context(context)
352
+ project_id = _validated_identifier(context, runtime.project, label="project")
353
+ validated_key_id = _validated_identifier(context, key_id, label="key_id")
354
+ credential = _jwt_credential(context, operation="revoke")
355
+ project_path = quote(project_id, safe="")
356
+ key_path = quote(validated_key_id, safe="")
357
+ try:
358
+ with SolwynClient(api_url=runtime.api_url, credential=credential) as client:
359
+ response = client.request(
360
+ "DELETE",
361
+ f"/api/v1/projects/{project_path}/key/{key_path}",
362
+ retry_rate_limit=False,
363
+ retry_auth=False,
364
+ )
365
+ if response.status_code != 204 or response.content:
366
+ raise ValueError("Key revocation did not return a bodyless 204")
367
+ except ApiError as error:
368
+ exit_with_error(context, _actionable_api_error(error))
369
+ except AuthError as error:
370
+ exit_with_error(
371
+ context,
372
+ OutputError(code="authentication_failed", message=str(error)),
373
+ semantic=ErrorSemantic.AUTH,
374
+ )
375
+ except (ValidationError, ValueError, TypeError):
376
+ _invalid_response(context)
377
+
378
+ data = _ProjectKeyRevokeData(
379
+ project_id=project_id,
380
+ key_id=validated_key_id,
381
+ )
382
+ output_engine(context).write_success(
383
+ schema="solwyn.keys-revoke.v1",
384
+ data=data,
385
+ table=_revoke_table(data),
386
+ )
@@ -0,0 +1,205 @@
1
+ """Login and logout commands."""
2
+
3
+ import sys
4
+ from typing import Annotated, Literal
5
+
6
+ import typer
7
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
8
+
9
+ from solwyn_cli._vendor.wire import LoginRequest
10
+ from solwyn_cli.auth import (
11
+ AuthError,
12
+ CredentialStorageError,
13
+ JwtCredential,
14
+ ProjectKeyCredential,
15
+ clear_credentials,
16
+ store_jwt_pair,
17
+ store_project_key,
18
+ )
19
+ from solwyn_cli.client import SolwynClient
20
+ from solwyn_cli.commands._shared import exit_with_error
21
+ from solwyn_cli.errors import ApiError
22
+ from solwyn_cli.exit_codes import ErrorSemantic
23
+ from solwyn_cli.help import help_contract
24
+ from solwyn_cli.output import OutputError, TableData
25
+ from solwyn_cli.runtime import command_context, output_engine
26
+
27
+
28
+ LOGIN_HELP = help_contract(
29
+ endpoint="POST /api/v1/auth/login or local keychain write with --with-key",
30
+ auth="none",
31
+ exits="0 success; 1 generic/API; 2 usage; 3 auth or credential storage",
32
+ schema="solwyn.login.v1",
33
+ guidance=(
34
+ "Keys and passwords are never accepted as command-line arguments or echoed.",
35
+ "Read project keys only from a hidden prompt or stdin; passwords use a hidden prompt.",
36
+ ),
37
+ )
38
+
39
+ LOGOUT_HELP = help_contract(
40
+ endpoint="local keychain clear",
41
+ auth="local (none)",
42
+ exits="0 success; 2 usage; 3 credential storage",
43
+ schema="solwyn.logout.v1",
44
+ )
45
+
46
+
47
+ class _LoginTokenResponse(BaseModel):
48
+ """Validated login response whose tokens remain redacted in memory."""
49
+
50
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
51
+
52
+ access_token: SecretStr = Field(min_length=1)
53
+ refresh_token: SecretStr = Field(min_length=1)
54
+ token_type: Literal["bearer"] = "bearer"
55
+
56
+
57
+ def authenticate_with_project_key(project_key: str) -> ProjectKeyCredential:
58
+ """Validate and store one project key through the shared auth boundary."""
59
+ return store_project_key(project_key)
60
+
61
+
62
+ def authenticate_with_password(
63
+ *, api_url: str, email: str, password: str
64
+ ) -> JwtCredential:
65
+ """Authenticate, validate the token response, and store the JWT pair."""
66
+ request = LoginRequest(email=email, password=password)
67
+ with SolwynClient(api_url=api_url, credential=None) as client:
68
+ response = client.request_unauthenticated(
69
+ "POST",
70
+ "/api/v1/auth/login",
71
+ json=request.model_dump(),
72
+ )
73
+ tokens = _LoginTokenResponse.model_validate(response.json())
74
+ return store_jwt_pair(
75
+ tokens.access_token.get_secret_value(),
76
+ tokens.refresh_token.get_secret_value(),
77
+ )
78
+
79
+
80
+ def _stdin_is_tty() -> bool:
81
+ return sys.stdin.isatty()
82
+
83
+
84
+ def _login_success(context: typer.Context, auth_type: str) -> None:
85
+ output_engine(context).write_success(
86
+ schema="solwyn.login.v1",
87
+ data={"auth_type": auth_type},
88
+ table=TableData(
89
+ columns=("Authentication", "Status"),
90
+ rows=((auth_type, "authenticated"),),
91
+ right_align=("Status",),
92
+ ),
93
+ )
94
+
95
+
96
+ def _login_with_key(context: typer.Context) -> None:
97
+ project_key = (
98
+ typer.prompt("Project key", hide_input=True, err=True)
99
+ if _stdin_is_tty()
100
+ else sys.stdin.read().strip()
101
+ )
102
+ try:
103
+ authenticate_with_project_key(project_key)
104
+ except CredentialStorageError as error:
105
+ exit_with_error(
106
+ context,
107
+ OutputError(code="credential_storage_failed", message=str(error)),
108
+ semantic=ErrorSemantic.AUTH,
109
+ )
110
+ except AuthError:
111
+ exit_with_error(
112
+ context,
113
+ OutputError(
114
+ code="invalid_project_key",
115
+ message="The project key is empty or malformed.",
116
+ ),
117
+ semantic=ErrorSemantic.AUTH,
118
+ )
119
+ _login_success(context, "project_key")
120
+
121
+
122
+ def _login_with_password(context: typer.Context) -> None:
123
+ if not _stdin_is_tty():
124
+ exit_with_error(
125
+ context,
126
+ OutputError(
127
+ code="interactive_auth_required",
128
+ message="Email and password login requires an interactive terminal.",
129
+ hint="Set SOLWYN_API_KEY or pipe a project key to login --with-key.",
130
+ ),
131
+ semantic=ErrorSemantic.AUTH,
132
+ )
133
+ email = typer.prompt("Email", err=True)
134
+ password = typer.prompt("Password", hide_input=True, err=True)
135
+ try:
136
+ runtime = command_context(context)
137
+ authenticate_with_password(
138
+ api_url=runtime.api_url,
139
+ email=email,
140
+ password=password,
141
+ )
142
+ except ApiError as error:
143
+ if error.status_code == 403 and error.code == "email_not_verified":
144
+ error = ApiError(
145
+ status_code=error.status_code,
146
+ code=error.code,
147
+ message=error.message,
148
+ hint="Verify your email in the dashboard, then try logging in again.",
149
+ detail=error.detail,
150
+ )
151
+ exit_with_error(context, error, semantic=ErrorSemantic.AUTH)
152
+ except CredentialStorageError as error:
153
+ exit_with_error(
154
+ context,
155
+ OutputError(code="credential_storage_failed", message=str(error)),
156
+ semantic=ErrorSemantic.AUTH,
157
+ )
158
+ except (ValidationError, ValueError, TypeError):
159
+ exit_with_error(
160
+ context,
161
+ OutputError(
162
+ code="invalid_auth_response",
163
+ message="The authentication response was invalid.",
164
+ ),
165
+ semantic=ErrorSemantic.AUTH,
166
+ )
167
+ _login_success(context, "jwt")
168
+
169
+
170
+ def login(
171
+ context: typer.Context,
172
+ with_key: Annotated[
173
+ bool,
174
+ typer.Option(
175
+ "--with-key",
176
+ help="Read and store a project API key from a hidden prompt or stdin.",
177
+ ),
178
+ ] = False,
179
+ ) -> None:
180
+ """Authenticate with Solwyn."""
181
+ if with_key:
182
+ _login_with_key(context)
183
+ else:
184
+ _login_with_password(context)
185
+
186
+
187
+ def logout(context: typer.Context) -> None:
188
+ """Clear all stored Solwyn credentials."""
189
+ try:
190
+ clear_credentials()
191
+ except CredentialStorageError as error:
192
+ exit_with_error(
193
+ context,
194
+ OutputError(code="credential_storage_failed", message=str(error)),
195
+ semantic=ErrorSemantic.AUTH,
196
+ )
197
+ output_engine(context).write_success(
198
+ schema="solwyn.logout.v1",
199
+ data={"cleared": True},
200
+ table=TableData(
201
+ columns=("Credentials", "Status"),
202
+ rows=(("all", "cleared"),),
203
+ right_align=("Status",),
204
+ ),
205
+ )
@@ -0,0 +1,28 @@
1
+ """Run the local Solwyn MCP server over stdio."""
2
+
3
+ import typer
4
+
5
+ from solwyn_cli.help import help_contract
6
+ from solwyn_cli.mcp.server import McpLaunchOptions, TOOL_NAMES, create_server
7
+
8
+
9
+ MCP_HELP = help_contract(
10
+ endpoint="local stdio MCP server",
11
+ auth="resolved per tool call",
12
+ exits="0 clean shutdown; 1 startup or protocol failure; 2 usage",
13
+ schema="MCP protocol (stdio JSON-RPC)",
14
+ guidance=(
15
+ "Protocol: stdio only; stdout is reserved for MCP JSON-RPC framing.",
16
+ "Runtime configuration and credentials are resolved anew for every tool call.",
17
+ f"Tools: {', '.join(TOOL_NAMES)}.",
18
+ "No telemetry, update checks, provider keys, prompts, or responses are collected.",
19
+ ),
20
+ )
21
+
22
+
23
+ def mcp(context: typer.Context) -> None:
24
+ """Serve Solwyn tools over local MCP stdio."""
25
+ launch_options = (
26
+ context.obj if isinstance(context.obj, McpLaunchOptions) else McpLaunchOptions()
27
+ )
28
+ create_server(launch_options=launch_options).run(transport="stdio")