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,659 @@
|
|
|
1
|
+
"""Safe end-to-end onboarding for a repository."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Literal
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
12
|
+
|
|
13
|
+
from solwyn_cli._vendor.wire import ProjectCreateRequest
|
|
14
|
+
from solwyn_cli.artifacts import ArtifactRecoveryError, write_agent_artifacts
|
|
15
|
+
from solwyn_cli.auth import (
|
|
16
|
+
AuthError,
|
|
17
|
+
CredentialStorageError,
|
|
18
|
+
JwtCredential,
|
|
19
|
+
ProjectKeyCredential,
|
|
20
|
+
ResolvedCredential,
|
|
21
|
+
resolve_credential,
|
|
22
|
+
store_project_key,
|
|
23
|
+
)
|
|
24
|
+
from solwyn_cli.client import SolwynClient
|
|
25
|
+
from solwyn_cli.commands._shared import exit_with_error
|
|
26
|
+
from solwyn_cli.commands.budget import BudgetStatusData, fetch_budget_status
|
|
27
|
+
from solwyn_cli.commands.login import (
|
|
28
|
+
authenticate_with_password,
|
|
29
|
+
authenticate_with_project_key,
|
|
30
|
+
)
|
|
31
|
+
from solwyn_cli.commands.projects import (
|
|
32
|
+
fetch_projects,
|
|
33
|
+
request_project_creation,
|
|
34
|
+
)
|
|
35
|
+
from solwyn_cli.config import (
|
|
36
|
+
Config,
|
|
37
|
+
ConfigLoadError,
|
|
38
|
+
ConfigWriteError,
|
|
39
|
+
get_config_path,
|
|
40
|
+
update_config_values,
|
|
41
|
+
)
|
|
42
|
+
from solwyn_cli.errors import ApiError
|
|
43
|
+
from solwyn_cli.exit_codes import ErrorSemantic
|
|
44
|
+
from solwyn_cli.help import help_contract
|
|
45
|
+
from solwyn_cli.output import OutputError, TableData
|
|
46
|
+
from solwyn_cli.runtime import command_context, output_engine
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_DEFAULT_API_URL = Config().api_url
|
|
50
|
+
_MAX_PROVIDER_SOURCE_FILES = 2_000
|
|
51
|
+
_MAX_PROVIDER_SOURCE_BYTES = 1_000_000
|
|
52
|
+
_MAX_PROVIDER_TOTAL_BYTES = 10_000_000
|
|
53
|
+
_IGNORED_DIRECTORIES = frozenset(
|
|
54
|
+
{
|
|
55
|
+
".git",
|
|
56
|
+
".hg",
|
|
57
|
+
".mypy_cache",
|
|
58
|
+
".pytest_cache",
|
|
59
|
+
".ruff_cache",
|
|
60
|
+
".tox",
|
|
61
|
+
".venv",
|
|
62
|
+
"__pycache__",
|
|
63
|
+
"build",
|
|
64
|
+
"dist",
|
|
65
|
+
"node_modules",
|
|
66
|
+
"venv",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
Provider = Literal["openai", "anthropic", "google-genai", "boto3"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
INIT_HELP = help_contract(
|
|
73
|
+
endpoint="composite onboarding workflow",
|
|
74
|
+
auth="any or interactive bootstrap",
|
|
75
|
+
exits="0 success; 1 generic/API; 2 usage; 3 auth; 5 not found",
|
|
76
|
+
schema="solwyn.init.v1",
|
|
77
|
+
guidance=(
|
|
78
|
+
"Remote operations: conditional POST /api/v1/auth/login; "
|
|
79
|
+
"GET /api/v1/projects; optional POST /api/v1/projects; "
|
|
80
|
+
"final GET /api/v1/projects/{project}/budget.",
|
|
81
|
+
"Local operations: write local configuration; scan Python imports; "
|
|
82
|
+
"render SDK snippets; optionally write agent artifacts with --agents.",
|
|
83
|
+
"Provider credentials are never collected; --agents writes safe agent guidance.",
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
_PROVIDER_ORDER: tuple[Provider, ...] = (
|
|
87
|
+
"openai",
|
|
88
|
+
"anthropic",
|
|
89
|
+
"google-genai",
|
|
90
|
+
"boto3",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Snippet(BaseModel):
|
|
95
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
96
|
+
|
|
97
|
+
provider: Provider | Literal["generic"]
|
|
98
|
+
code: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _Verification(BaseModel):
|
|
102
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
103
|
+
|
|
104
|
+
status: Literal["verified"] = "verified"
|
|
105
|
+
budget_limit: float = Field(allow_inf_nan=False)
|
|
106
|
+
current_usage: float = Field(allow_inf_nan=False)
|
|
107
|
+
remaining_budget: float = Field(allow_inf_nan=False)
|
|
108
|
+
utilization_pct: float = Field(allow_inf_nan=False)
|
|
109
|
+
budget_mode: str
|
|
110
|
+
budget_period: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _InitData(BaseModel):
|
|
114
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
115
|
+
|
|
116
|
+
project_id: str
|
|
117
|
+
config_path: str
|
|
118
|
+
api_url: str
|
|
119
|
+
detected_providers: list[Provider]
|
|
120
|
+
snippets: list[_Snippet]
|
|
121
|
+
verification: _Verification
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _stdin_is_tty() -> bool:
|
|
125
|
+
return sys.stdin.isatty()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _authentication_required(context: typer.Context) -> None:
|
|
129
|
+
exit_with_error(
|
|
130
|
+
context,
|
|
131
|
+
OutputError(
|
|
132
|
+
code="interactive_auth_required",
|
|
133
|
+
message="Authentication is required to initialize Solwyn.",
|
|
134
|
+
hint="Set SOLWYN_API_KEY or rerun init in an interactive terminal.",
|
|
135
|
+
),
|
|
136
|
+
semantic=ErrorSemantic.AUTH,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _prompt_for_credential(context: typer.Context) -> ResolvedCredential:
|
|
141
|
+
if not _stdin_is_tty():
|
|
142
|
+
_authentication_required(context)
|
|
143
|
+
|
|
144
|
+
method = (
|
|
145
|
+
typer.prompt(
|
|
146
|
+
"Authentication method (key/login)",
|
|
147
|
+
default="key",
|
|
148
|
+
err=True,
|
|
149
|
+
)
|
|
150
|
+
.strip()
|
|
151
|
+
.lower()
|
|
152
|
+
)
|
|
153
|
+
if method not in {"key", "login"}:
|
|
154
|
+
exit_with_error(
|
|
155
|
+
context,
|
|
156
|
+
OutputError(
|
|
157
|
+
code="invalid_auth_method",
|
|
158
|
+
message="Authentication method must be key or login.",
|
|
159
|
+
),
|
|
160
|
+
semantic=ErrorSemantic.USAGE,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if method == "key":
|
|
164
|
+
project_key = typer.prompt("Project key", hide_input=True, err=True)
|
|
165
|
+
try:
|
|
166
|
+
return authenticate_with_project_key(project_key)
|
|
167
|
+
except CredentialStorageError as error:
|
|
168
|
+
exit_with_error(
|
|
169
|
+
context,
|
|
170
|
+
OutputError(code="credential_storage_failed", message=str(error)),
|
|
171
|
+
semantic=ErrorSemantic.AUTH,
|
|
172
|
+
)
|
|
173
|
+
except AuthError:
|
|
174
|
+
exit_with_error(
|
|
175
|
+
context,
|
|
176
|
+
OutputError(
|
|
177
|
+
code="invalid_project_key",
|
|
178
|
+
message="The project key is empty or malformed.",
|
|
179
|
+
),
|
|
180
|
+
semantic=ErrorSemantic.AUTH,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
email = typer.prompt("Email", err=True)
|
|
184
|
+
password = typer.prompt("Password", hide_input=True, err=True)
|
|
185
|
+
runtime = command_context(context)
|
|
186
|
+
try:
|
|
187
|
+
return authenticate_with_password(
|
|
188
|
+
api_url=runtime.api_url,
|
|
189
|
+
email=email,
|
|
190
|
+
password=password,
|
|
191
|
+
)
|
|
192
|
+
except ApiError as error:
|
|
193
|
+
exit_with_error(context, error, semantic=ErrorSemantic.AUTH)
|
|
194
|
+
except CredentialStorageError as error:
|
|
195
|
+
exit_with_error(
|
|
196
|
+
context,
|
|
197
|
+
OutputError(code="credential_storage_failed", message=str(error)),
|
|
198
|
+
semantic=ErrorSemantic.AUTH,
|
|
199
|
+
)
|
|
200
|
+
except (ValidationError, ValueError, TypeError):
|
|
201
|
+
exit_with_error(
|
|
202
|
+
context,
|
|
203
|
+
OutputError(
|
|
204
|
+
code="invalid_auth_response",
|
|
205
|
+
message="The authentication response was invalid.",
|
|
206
|
+
),
|
|
207
|
+
semantic=ErrorSemantic.AUTH,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _resolve_auth(context: typer.Context) -> ResolvedCredential:
|
|
212
|
+
try:
|
|
213
|
+
credential = resolve_credential()
|
|
214
|
+
except AuthError as error:
|
|
215
|
+
exit_with_error(
|
|
216
|
+
context,
|
|
217
|
+
OutputError(code="authentication_failed", message=str(error)),
|
|
218
|
+
semantic=ErrorSemantic.AUTH,
|
|
219
|
+
)
|
|
220
|
+
if credential is not None:
|
|
221
|
+
return credential
|
|
222
|
+
return _prompt_for_credential(context)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _create_project(
|
|
226
|
+
context: typer.Context,
|
|
227
|
+
client: SolwynClient,
|
|
228
|
+
) -> tuple[str, ResolvedCredential | None]:
|
|
229
|
+
name = typer.prompt("Project name", err=True).strip()
|
|
230
|
+
budget = typer.prompt("Initial monthly budget", type=float, default=100.0, err=True)
|
|
231
|
+
if not name or not math.isfinite(budget) or budget <= 0:
|
|
232
|
+
exit_with_error(
|
|
233
|
+
context,
|
|
234
|
+
OutputError(
|
|
235
|
+
code="invalid_project_create_request",
|
|
236
|
+
message="Project name and budget are invalid.",
|
|
237
|
+
hint="Provide a nonblank name and a finite positive budget.",
|
|
238
|
+
),
|
|
239
|
+
semantic=ErrorSemantic.USAGE,
|
|
240
|
+
)
|
|
241
|
+
try:
|
|
242
|
+
request = ProjectCreateRequest(
|
|
243
|
+
name=name,
|
|
244
|
+
budget_limit=budget,
|
|
245
|
+
budget_period="monthly",
|
|
246
|
+
budget_mode="alert_only",
|
|
247
|
+
)
|
|
248
|
+
created = request_project_creation(
|
|
249
|
+
client=client,
|
|
250
|
+
request=request,
|
|
251
|
+
)
|
|
252
|
+
stored_credential = store_project_key(created.key.get_secret_value())
|
|
253
|
+
except ApiError as error:
|
|
254
|
+
exit_with_error(context, error)
|
|
255
|
+
except CredentialStorageError as error:
|
|
256
|
+
exit_with_error(
|
|
257
|
+
context,
|
|
258
|
+
OutputError(code="credential_storage_failed", message=str(error)),
|
|
259
|
+
semantic=ErrorSemantic.AUTH,
|
|
260
|
+
)
|
|
261
|
+
except (ValidationError, ValueError, TypeError):
|
|
262
|
+
exit_with_error(
|
|
263
|
+
context,
|
|
264
|
+
OutputError(
|
|
265
|
+
code="invalid_projects_response",
|
|
266
|
+
message="The project creation response was invalid.",
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
return created.id, (
|
|
270
|
+
stored_credential
|
|
271
|
+
if isinstance(stored_credential, ProjectKeyCredential)
|
|
272
|
+
else None
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _resolve_jwt_project(
|
|
277
|
+
context: typer.Context,
|
|
278
|
+
credential: JwtCredential,
|
|
279
|
+
explicit_project: str | None,
|
|
280
|
+
) -> tuple[str, ResolvedCredential]:
|
|
281
|
+
runtime = command_context(context)
|
|
282
|
+
try:
|
|
283
|
+
with SolwynClient(api_url=runtime.api_url, credential=credential) as client:
|
|
284
|
+
projects = fetch_projects(client=client)
|
|
285
|
+
by_id = {project.id: project for project in projects}
|
|
286
|
+
if explicit_project is not None:
|
|
287
|
+
if explicit_project not in by_id:
|
|
288
|
+
exit_with_error(
|
|
289
|
+
context,
|
|
290
|
+
OutputError(
|
|
291
|
+
code="project_not_found",
|
|
292
|
+
message="The selected project is not available.",
|
|
293
|
+
hint="Choose a project returned by solwyn projects list.",
|
|
294
|
+
),
|
|
295
|
+
semantic=ErrorSemantic.USAGE,
|
|
296
|
+
)
|
|
297
|
+
return explicit_project, client.current_credential
|
|
298
|
+
if len(projects) == 1:
|
|
299
|
+
return projects[0].id, client.current_credential
|
|
300
|
+
if not _stdin_is_tty():
|
|
301
|
+
exit_with_error(
|
|
302
|
+
context,
|
|
303
|
+
OutputError(
|
|
304
|
+
code="project_required",
|
|
305
|
+
message="A project must be selected for initialization.",
|
|
306
|
+
hint="Choose one with --project or SOLWYN_PROJECT_ID.",
|
|
307
|
+
),
|
|
308
|
+
semantic=ErrorSemantic.USAGE,
|
|
309
|
+
)
|
|
310
|
+
if not projects:
|
|
311
|
+
project_id, stored = _create_project(context, client)
|
|
312
|
+
return project_id, stored or client.current_credential
|
|
313
|
+
|
|
314
|
+
project_ids = ", ".join(project.id for project in projects)
|
|
315
|
+
selection = typer.prompt(
|
|
316
|
+
f"Project ({project_ids}, or create)",
|
|
317
|
+
err=True,
|
|
318
|
+
).strip()
|
|
319
|
+
if selection == "create":
|
|
320
|
+
project_id, stored = _create_project(context, client)
|
|
321
|
+
return project_id, stored or client.current_credential
|
|
322
|
+
if selection not in by_id:
|
|
323
|
+
exit_with_error(
|
|
324
|
+
context,
|
|
325
|
+
OutputError(
|
|
326
|
+
code="project_not_found",
|
|
327
|
+
message="The selected project is not available.",
|
|
328
|
+
hint="Choose a listed project ID or enter create.",
|
|
329
|
+
),
|
|
330
|
+
semantic=ErrorSemantic.USAGE,
|
|
331
|
+
)
|
|
332
|
+
return selection, client.current_credential
|
|
333
|
+
except ApiError as error:
|
|
334
|
+
exit_with_error(context, error)
|
|
335
|
+
except AuthError as error:
|
|
336
|
+
exit_with_error(
|
|
337
|
+
context,
|
|
338
|
+
OutputError(code="authentication_failed", message=str(error)),
|
|
339
|
+
semantic=ErrorSemantic.AUTH,
|
|
340
|
+
)
|
|
341
|
+
except (ValidationError, ValueError, TypeError):
|
|
342
|
+
exit_with_error(
|
|
343
|
+
context,
|
|
344
|
+
OutputError(
|
|
345
|
+
code="invalid_projects_response",
|
|
346
|
+
message="The projects response was invalid.",
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _resolve_project(
|
|
352
|
+
context: typer.Context,
|
|
353
|
+
credential: ResolvedCredential,
|
|
354
|
+
) -> tuple[str, ResolvedCredential]:
|
|
355
|
+
selected = command_context(context).project
|
|
356
|
+
if isinstance(credential, ProjectKeyCredential):
|
|
357
|
+
if selected is None:
|
|
358
|
+
exit_with_error(
|
|
359
|
+
context,
|
|
360
|
+
OutputError(
|
|
361
|
+
code="project_required",
|
|
362
|
+
message="A project key cannot discover its project yet.",
|
|
363
|
+
hint="Choose the key's project with --project.",
|
|
364
|
+
),
|
|
365
|
+
semantic=ErrorSemantic.USAGE,
|
|
366
|
+
)
|
|
367
|
+
return selected, credential
|
|
368
|
+
return _resolve_jwt_project(context, credential, selected)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _write_config(api_url: str, project_id: str) -> Path:
|
|
372
|
+
update_config_values(
|
|
373
|
+
{
|
|
374
|
+
"default_project": project_id,
|
|
375
|
+
"api_url": api_url if api_url != _DEFAULT_API_URL else _DEFAULT_API_URL,
|
|
376
|
+
},
|
|
377
|
+
omit_default_api_url=True,
|
|
378
|
+
)
|
|
379
|
+
return get_config_path()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _persist_config(context: typer.Context, api_url: str, project_id: str) -> Path:
|
|
383
|
+
try:
|
|
384
|
+
return _write_config(api_url, project_id)
|
|
385
|
+
except ConfigLoadError:
|
|
386
|
+
exit_with_error(
|
|
387
|
+
context,
|
|
388
|
+
OutputError(
|
|
389
|
+
code="config_load_failed",
|
|
390
|
+
message="The existing configuration could not be loaded.",
|
|
391
|
+
hint="Repair or remove the configuration file, then retry init.",
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
except ConfigWriteError:
|
|
395
|
+
exit_with_error(
|
|
396
|
+
context,
|
|
397
|
+
OutputError(
|
|
398
|
+
code="config_write_failed",
|
|
399
|
+
message="The configuration could not be written.",
|
|
400
|
+
hint="Check configuration directory permissions, then retry init.",
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _imports_in(tree: ast.AST) -> tuple[set[Provider], set[Provider]]:
|
|
406
|
+
detected: set[Provider] = set()
|
|
407
|
+
module_imports: set[Provider] = set()
|
|
408
|
+
for node in ast.walk(tree):
|
|
409
|
+
if isinstance(node, ast.Import):
|
|
410
|
+
names = {alias.name for alias in node.names}
|
|
411
|
+
if "openai" in names:
|
|
412
|
+
detected.add("openai")
|
|
413
|
+
module_imports.add("openai")
|
|
414
|
+
if "anthropic" in names:
|
|
415
|
+
detected.add("anthropic")
|
|
416
|
+
module_imports.add("anthropic")
|
|
417
|
+
if "boto3" in names:
|
|
418
|
+
detected.add("boto3")
|
|
419
|
+
module_imports.add("boto3")
|
|
420
|
+
if "google.genai" in names:
|
|
421
|
+
detected.add("google-genai")
|
|
422
|
+
module_imports.add("google-genai")
|
|
423
|
+
elif isinstance(node, ast.ImportFrom):
|
|
424
|
+
imported = {alias.name for alias in node.names}
|
|
425
|
+
if node.module == "openai":
|
|
426
|
+
detected.add("openai")
|
|
427
|
+
if node.module == "anthropic":
|
|
428
|
+
detected.add("anthropic")
|
|
429
|
+
if node.module == "google" and "genai" in imported:
|
|
430
|
+
detected.add("google-genai")
|
|
431
|
+
if node.module == "google.genai":
|
|
432
|
+
detected.add("google-genai")
|
|
433
|
+
if node.module == "boto3":
|
|
434
|
+
detected.add("boto3")
|
|
435
|
+
return detected, module_imports
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _detect_providers(root: Path) -> tuple[list[Provider], set[Provider]]:
|
|
439
|
+
scan_root = _provider_scan_root(root)
|
|
440
|
+
detected: set[Provider] = set()
|
|
441
|
+
module_imports: set[Provider] = set()
|
|
442
|
+
inspected_files = 0
|
|
443
|
+
inspected_bytes = 0
|
|
444
|
+
for directory, directory_names, file_names in os.walk(scan_root):
|
|
445
|
+
if (Path(directory) / "pyvenv.cfg").is_file():
|
|
446
|
+
directory_names.clear()
|
|
447
|
+
continue
|
|
448
|
+
directory_names[:] = sorted(
|
|
449
|
+
name
|
|
450
|
+
for name in directory_names
|
|
451
|
+
if name not in _IGNORED_DIRECTORIES
|
|
452
|
+
and not (Path(directory) / name / ".git").exists()
|
|
453
|
+
)
|
|
454
|
+
for file_name in sorted(file_names):
|
|
455
|
+
if not file_name.endswith(".py"):
|
|
456
|
+
continue
|
|
457
|
+
if inspected_files >= _MAX_PROVIDER_SOURCE_FILES:
|
|
458
|
+
return (
|
|
459
|
+
[provider for provider in _PROVIDER_ORDER if provider in detected],
|
|
460
|
+
module_imports,
|
|
461
|
+
)
|
|
462
|
+
inspected_files += 1
|
|
463
|
+
path = Path(directory) / file_name
|
|
464
|
+
try:
|
|
465
|
+
if path.is_symlink():
|
|
466
|
+
continue
|
|
467
|
+
file_size = path.stat(follow_symlinks=False).st_size
|
|
468
|
+
if file_size > _MAX_PROVIDER_SOURCE_BYTES:
|
|
469
|
+
continue
|
|
470
|
+
if inspected_bytes + file_size > _MAX_PROVIDER_TOTAL_BYTES:
|
|
471
|
+
return (
|
|
472
|
+
[
|
|
473
|
+
provider
|
|
474
|
+
for provider in _PROVIDER_ORDER
|
|
475
|
+
if provider in detected
|
|
476
|
+
],
|
|
477
|
+
module_imports,
|
|
478
|
+
)
|
|
479
|
+
inspected_bytes += file_size
|
|
480
|
+
source = path.read_bytes()
|
|
481
|
+
if b"\0" in source:
|
|
482
|
+
continue
|
|
483
|
+
tree = ast.parse(source.decode("utf-8"), filename=str(path))
|
|
484
|
+
except (OSError, UnicodeError, SyntaxError):
|
|
485
|
+
continue
|
|
486
|
+
file_providers, file_module_imports = _imports_in(tree)
|
|
487
|
+
detected.update(file_providers)
|
|
488
|
+
module_imports.update(file_module_imports)
|
|
489
|
+
return (
|
|
490
|
+
[provider for provider in _PROVIDER_ORDER if provider in detected],
|
|
491
|
+
module_imports,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _provider_scan_root(start: Path) -> Path:
|
|
496
|
+
"""Resolve the nearest containing Git root or a bounded cwd fallback."""
|
|
497
|
+
resolved = start.resolve()
|
|
498
|
+
for candidate in (resolved, *resolved.parents):
|
|
499
|
+
if (candidate / ".git").exists():
|
|
500
|
+
return candidate
|
|
501
|
+
return resolved
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _snippets(
|
|
505
|
+
providers: list[Provider], module_imports: set[Provider]
|
|
506
|
+
) -> list[_Snippet]:
|
|
507
|
+
code = {
|
|
508
|
+
"openai": (
|
|
509
|
+
"import openai; from solwyn import Solwyn; "
|
|
510
|
+
'client = Solwyn(openai.OpenAI(api_key="<OPENAI_API_KEY>"))'
|
|
511
|
+
if "openai" in module_imports
|
|
512
|
+
else "from openai import OpenAI; from solwyn import Solwyn; "
|
|
513
|
+
'client = Solwyn(OpenAI(api_key="<OPENAI_API_KEY>"))'
|
|
514
|
+
),
|
|
515
|
+
"anthropic": (
|
|
516
|
+
"import anthropic; from solwyn import Solwyn; "
|
|
517
|
+
'client = Solwyn(anthropic.Anthropic(api_key="<ANTHROPIC_API_KEY>"))'
|
|
518
|
+
if "anthropic" in module_imports
|
|
519
|
+
else "from anthropic import Anthropic; from solwyn import Solwyn; "
|
|
520
|
+
'client = Solwyn(Anthropic(api_key="<ANTHROPIC_API_KEY>"))'
|
|
521
|
+
),
|
|
522
|
+
"google-genai": (
|
|
523
|
+
"from google import genai; from solwyn import Solwyn; "
|
|
524
|
+
'client = Solwyn(genai.Client(api_key="<GOOGLE_API_KEY>"))'
|
|
525
|
+
),
|
|
526
|
+
"boto3": (
|
|
527
|
+
"import boto3; from solwyn import Solwyn; bedrock = boto3.client("
|
|
528
|
+
'"bedrock-runtime", region_name="<AWS_REGION>"); client = Solwyn(bedrock)'
|
|
529
|
+
),
|
|
530
|
+
}
|
|
531
|
+
if not providers:
|
|
532
|
+
return [
|
|
533
|
+
_Snippet(
|
|
534
|
+
provider="generic",
|
|
535
|
+
code=(
|
|
536
|
+
"from solwyn import Solwyn; client = Solwyn(<YOUR_PROVIDER_CLIENT>)"
|
|
537
|
+
),
|
|
538
|
+
)
|
|
539
|
+
]
|
|
540
|
+
return [_Snippet(provider=provider, code=code[provider]) for provider in providers]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _verify_budget(
|
|
544
|
+
context: typer.Context,
|
|
545
|
+
credential: ResolvedCredential,
|
|
546
|
+
project_id: str,
|
|
547
|
+
) -> BudgetStatusData:
|
|
548
|
+
runtime = command_context(context)
|
|
549
|
+
try:
|
|
550
|
+
return fetch_budget_status(
|
|
551
|
+
api_url=runtime.api_url,
|
|
552
|
+
credential=credential,
|
|
553
|
+
project_id=project_id,
|
|
554
|
+
)
|
|
555
|
+
except ApiError as error:
|
|
556
|
+
exit_with_error(context, error)
|
|
557
|
+
except AuthError as error:
|
|
558
|
+
exit_with_error(
|
|
559
|
+
context,
|
|
560
|
+
OutputError(code="authentication_failed", message=str(error)),
|
|
561
|
+
semantic=ErrorSemantic.AUTH,
|
|
562
|
+
)
|
|
563
|
+
except (ValidationError, ValueError, TypeError):
|
|
564
|
+
exit_with_error(
|
|
565
|
+
context,
|
|
566
|
+
OutputError(
|
|
567
|
+
code="invalid_budget_response",
|
|
568
|
+
message="The budget verification response was invalid.",
|
|
569
|
+
),
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _table(data: _InitData) -> TableData:
|
|
574
|
+
snippet_text = " | ".join(snippet.code for snippet in data.snippets)
|
|
575
|
+
return TableData(
|
|
576
|
+
columns=("Field", "Value"),
|
|
577
|
+
rows=(
|
|
578
|
+
("Project", data.project_id),
|
|
579
|
+
("Configuration", "written"),
|
|
580
|
+
("Providers", ", ".join(data.detected_providers) or "none detected"),
|
|
581
|
+
("SDK wiring", snippet_text),
|
|
582
|
+
("Budget verification", data.verification.status),
|
|
583
|
+
("Budget remaining", f"{data.verification.remaining_budget:.2f}"),
|
|
584
|
+
("Utilization", f"{data.verification.utilization_pct:.2f}%"),
|
|
585
|
+
),
|
|
586
|
+
right_align=("Value",),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def init(
|
|
591
|
+
context: typer.Context,
|
|
592
|
+
agents: Annotated[
|
|
593
|
+
bool,
|
|
594
|
+
typer.Option(
|
|
595
|
+
"--agents",
|
|
596
|
+
help="Write Claude, Cursor, and AGENTS.md guidance.",
|
|
597
|
+
),
|
|
598
|
+
] = False,
|
|
599
|
+
) -> None:
|
|
600
|
+
"""Configure Solwyn safely for the current repository."""
|
|
601
|
+
credential = _resolve_auth(context)
|
|
602
|
+
project_id, credential = _resolve_project(context, credential)
|
|
603
|
+
runtime = command_context(context)
|
|
604
|
+
config_path = _persist_config(context, runtime.api_url, project_id)
|
|
605
|
+
providers, module_imports = _detect_providers(Path.cwd())
|
|
606
|
+
snippets = _snippets(providers, module_imports)
|
|
607
|
+
budget = _verify_budget(context, credential, project_id)
|
|
608
|
+
if agents:
|
|
609
|
+
try:
|
|
610
|
+
write_agent_artifacts(_provider_scan_root(Path.cwd()))
|
|
611
|
+
except ArtifactRecoveryError as error:
|
|
612
|
+
details: list[str] = []
|
|
613
|
+
if error.recovery_paths:
|
|
614
|
+
details.append(
|
|
615
|
+
"retained backup(s): "
|
|
616
|
+
+ ", ".join(str(path) for path in error.recovery_paths)
|
|
617
|
+
)
|
|
618
|
+
if error.conflict_paths:
|
|
619
|
+
details.append(
|
|
620
|
+
"preserved concurrent target(s): "
|
|
621
|
+
+ ", ".join(str(path) for path in error.conflict_paths)
|
|
622
|
+
)
|
|
623
|
+
exit_with_error(
|
|
624
|
+
context,
|
|
625
|
+
OutputError(
|
|
626
|
+
code="agent_artifact_recovery_required",
|
|
627
|
+
message="Agent guidance could not be fully restored.",
|
|
628
|
+
hint=("Manual recovery is required; " + "; ".join(details)),
|
|
629
|
+
),
|
|
630
|
+
)
|
|
631
|
+
except (OSError, UnicodeError, ValueError):
|
|
632
|
+
exit_with_error(
|
|
633
|
+
context,
|
|
634
|
+
OutputError(
|
|
635
|
+
code="agent_artifact_write_failed",
|
|
636
|
+
message="Agent guidance could not be written.",
|
|
637
|
+
hint="Check repository permissions and AGENTS.md markers.",
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
data = _InitData(
|
|
641
|
+
project_id=project_id,
|
|
642
|
+
config_path=str(config_path),
|
|
643
|
+
api_url=runtime.api_url,
|
|
644
|
+
detected_providers=providers,
|
|
645
|
+
snippets=snippets,
|
|
646
|
+
verification=_Verification(
|
|
647
|
+
budget_limit=budget.budget_limit,
|
|
648
|
+
current_usage=budget.current_usage,
|
|
649
|
+
remaining_budget=budget.remaining_budget,
|
|
650
|
+
utilization_pct=budget.utilization_pct,
|
|
651
|
+
budget_mode=budget.budget_mode.value,
|
|
652
|
+
budget_period=budget.budget_period.value,
|
|
653
|
+
),
|
|
654
|
+
)
|
|
655
|
+
output_engine(context).write_success(
|
|
656
|
+
schema="solwyn.init.v1",
|
|
657
|
+
data=data,
|
|
658
|
+
table=_table(data),
|
|
659
|
+
)
|