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,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
+ )