lovarch-cli 0.2.1__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.
Files changed (122) hide show
  1. lovarch_cli/__init__.py +16 -0
  2. lovarch_cli/__main__.py +10 -0
  3. lovarch_cli/ai/__init__.py +21 -0
  4. lovarch_cli/ai/gateway.py +240 -0
  5. lovarch_cli/api.py +111 -0
  6. lovarch_cli/auth/__init__.py +32 -0
  7. lovarch_cli/auth/keyring_store.py +214 -0
  8. lovarch_cli/auth/local_server.py +165 -0
  9. lovarch_cli/auth/pkce.py +57 -0
  10. lovarch_cli/auth/session.py +189 -0
  11. lovarch_cli/cli.py +262 -0
  12. lovarch_cli/clients/__init__.py +33 -0
  13. lovarch_cli/clients/factory.py +54 -0
  14. lovarch_cli/clients/local_client.py +432 -0
  15. lovarch_cli/clients/lovarch_storage.py +174 -0
  16. lovarch_cli/clients/lovarch_supabase.py +295 -0
  17. lovarch_cli/clients/persistence.py +166 -0
  18. lovarch_cli/clients/storage.py +66 -0
  19. lovarch_cli/commands/__init__.py +10 -0
  20. lovarch_cli/commands/account.py +172 -0
  21. lovarch_cli/commands/audit.py +394 -0
  22. lovarch_cli/commands/config_cmd.py +80 -0
  23. lovarch_cli/commands/consolidate.py +217 -0
  24. lovarch_cli/commands/context_cmd.py +73 -0
  25. lovarch_cli/commands/dev.py +287 -0
  26. lovarch_cli/commands/do_cmd.py +120 -0
  27. lovarch_cli/commands/init.py +218 -0
  28. lovarch_cli/commands/jobs_cmd.py +95 -0
  29. lovarch_cli/commands/login.py +202 -0
  30. lovarch_cli/commands/mcp_cmd.py +26 -0
  31. lovarch_cli/commands/run.py +375 -0
  32. lovarch_cli/commands/signup.py +185 -0
  33. lovarch_cli/commands/status.py +243 -0
  34. lovarch_cli/commands/upgrade.py +108 -0
  35. lovarch_cli/commands/verifica_cmd.py +174 -0
  36. lovarch_cli/config.py +101 -0
  37. lovarch_cli/config_store.py +111 -0
  38. lovarch_cli/credits/__init__.py +35 -0
  39. lovarch_cli/credits/base.py +84 -0
  40. lovarch_cli/credits/factory.py +36 -0
  41. lovarch_cli/credits/local.py +34 -0
  42. lovarch_cli/credits/lovarch.py +56 -0
  43. lovarch_cli/i18n/__init__.py +27 -0
  44. lovarch_cli/i18n/loader.py +121 -0
  45. lovarch_cli/i18n/translations/en.json +168 -0
  46. lovarch_cli/i18n/translations/es.json +168 -0
  47. lovarch_cli/i18n/translations/it.json +168 -0
  48. lovarch_cli/i18n/translations/pt.json +168 -0
  49. lovarch_cli/mcp/__init__.py +9 -0
  50. lovarch_cli/mcp/server.py +199 -0
  51. lovarch_cli/mcp/tools.py +372 -0
  52. lovarch_cli/sample_downloader.py +255 -0
  53. lovarch_cli/squad/README.md +206 -0
  54. lovarch_cli/squad/agents/auditor-input.md +353 -0
  55. lovarch_cli/squad/agents/bim-engineer.md +404 -0
  56. lovarch_cli/squad/agents/briefing-architect.md +249 -0
  57. lovarch_cli/squad/agents/cad-engineer.md +278 -0
  58. lovarch_cli/squad/agents/capitolato-writer.md +256 -0
  59. lovarch_cli/squad/agents/computo-engineer.md +258 -0
  60. lovarch_cli/squad/agents/concept-designer.md +399 -0
  61. lovarch_cli/squad/agents/contratto-architect.md +243 -0
  62. lovarch_cli/squad/agents/deliverable-builder.md +253 -0
  63. lovarch_cli/squad/agents/energy-prelim.md +388 -0
  64. lovarch_cli/squad/agents/pratiche-it.md +251 -0
  65. lovarch_cli/squad/agents/progetto-chief.md +768 -0
  66. lovarch_cli/squad/agents/quality-dati.md +409 -0
  67. lovarch_cli/squad/agents/quality-misure.md +418 -0
  68. lovarch_cli/squad/agents/quality-normativa.md +417 -0
  69. lovarch_cli/squad/agents/quality-output.md +436 -0
  70. lovarch_cli/squad/agents/regolatorio-it.md +278 -0
  71. lovarch_cli/squad/checklists/handoff-quality-gate.md +232 -0
  72. lovarch_cli/squad/checklists/quality-dati-checklist.md +134 -0
  73. lovarch_cli/squad/checklists/quality-misure-checklist.md +139 -0
  74. lovarch_cli/squad/checklists/quality-normativa-checklist.md +121 -0
  75. lovarch_cli/squad/checklists/quality-output-checklist.md +116 -0
  76. lovarch_cli/squad/config.yaml +408 -0
  77. lovarch_cli/squad/data/CHANGELOG.md +272 -0
  78. lovarch_cli/squad/data/agents-prd.md +428 -0
  79. lovarch_cli/squad/data/architettura-progetto-rules.md +328 -0
  80. lovarch_cli/squad/data/handoff-card-template.md +231 -0
  81. lovarch_cli/squad/data/mocks/catasto-visura.json +72 -0
  82. lovarch_cli/squad/data/mocks/firma-envelope.json +43 -0
  83. lovarch_cli/squad/data/prezzario-lombardia-sample.json +312 -0
  84. lovarch_cli/squad/scripts/api_clients.py +206 -0
  85. lovarch_cli/squad/scripts/architect_profile.py +276 -0
  86. lovarch_cli/squad/scripts/deliverable_generators.py +844 -0
  87. lovarch_cli/squad/scripts/generate_attico_brera_dwg.py +369 -0
  88. lovarch_cli/squad/scripts/generate_chianti_dxf.py +368 -0
  89. lovarch_cli/squad/scripts/generate_chianti_images.py +223 -0
  90. lovarch_cli/squad/scripts/generate_real_sample_images.py +189 -0
  91. lovarch_cli/squad/scripts/generate_sample_assets.py +382 -0
  92. lovarch_cli/squad/scripts/lovarch_client.py +1046 -0
  93. lovarch_cli/squad/scripts/pipeline_runner.py +2095 -0
  94. lovarch_cli/squad/scripts/render_dxf_to_png.py +57 -0
  95. lovarch_cli/squad/scripts/run_palestra_demo.sh +277 -0
  96. lovarch_cli/squad/scripts/simulate_squad_execution.py +515 -0
  97. lovarch_cli/squad/scripts/validate-squad.py +383 -0
  98. lovarch_cli/squad/tasks/audit-input.md +146 -0
  99. lovarch_cli/squad/tasks/compute-metric.md +105 -0
  100. lovarch_cli/squad/tasks/consolidate-dossier.md +187 -0
  101. lovarch_cli/squad/tasks/generate-cad-plan.md +120 -0
  102. lovarch_cli/squad/tasks/generate-ifc-model.md +108 -0
  103. lovarch_cli/squad/tasks/write-capitolato.md +100 -0
  104. lovarch_cli/squad/templates/asseverazione-tecnica.md +126 -0
  105. lovarch_cli/squad/templates/capitolato-uni-11337.md +235 -0
  106. lovarch_cli/squad/templates/cila-comune-milano.md +177 -0
  107. lovarch_cli/squad/templates/contratto-cnappc.md +220 -0
  108. lovarch_cli/squad/workflows/dal-brief-al-cantiere.yaml +218 -0
  109. lovarch_cli/squad_loader.py +114 -0
  110. lovarch_cli/verify/__init__.py +15 -0
  111. lovarch_cli/verify/contratto.py +110 -0
  112. lovarch_cli/verify/dossier.py +97 -0
  113. lovarch_cli/verify/misure.py +83 -0
  114. lovarch_cli/verify/normativa.py +178 -0
  115. lovarch_cli/version.py +13 -0
  116. lovarch_cli/workflows/__init__.py +9 -0
  117. lovarch_cli/workflows/platform.py +212 -0
  118. lovarch_cli-0.2.1.dist-info/METADATA +232 -0
  119. lovarch_cli-0.2.1.dist-info/RECORD +122 -0
  120. lovarch_cli-0.2.1.dist-info/WHEEL +4 -0
  121. lovarch_cli-0.2.1.dist-info/entry_points.txt +3 -0
  122. lovarch_cli-0.2.1.dist-info/licenses/LICENSE +38 -0
@@ -0,0 +1,16 @@
1
+ """lovarch-cli — AI-powered architectural project execution CLI.
2
+
3
+ Squad di 17 agenti specializzati (mind clones di Schumacher, Baldwin, Mazria,
4
+ Deming, Juran, English, Dodds) che esegue audit, briefing, normativa IT, CAD,
5
+ BIM/IFC, computo metrico, capitolato, pratiche edilizie, contratto CNAPPC, energy/LCA
6
+ preliminare, dossier consolidato.
7
+
8
+ Two modes:
9
+ - Free: standalone (your own API keys, SQLite local, filesystem storage)
10
+ - Premium: Lovarch-integrated (Supabase + S3 + Edge Functions + credits)
11
+
12
+ Powered by Lovarch — https://lovarch.com
13
+ """
14
+ from lovarch_cli.version import __version__
15
+
16
+ __all__ = ["__version__"]
@@ -0,0 +1,10 @@
1
+ """Entry point for `python -m lovarch_cli`.
2
+
3
+ This allows users to invoke the CLI both as:
4
+ - `arch <command>` (via [project.scripts] entry point)
5
+ - `python -m lovarch_cli <command>` (via this module)
6
+ """
7
+ from lovarch_cli.cli import app
8
+
9
+ if __name__ == "__main__":
10
+ app()
@@ -0,0 +1,21 @@
1
+ """Lovarch AI gateway — routes CLI AI generation through the platform.
2
+
3
+ Every paid AI call the CLI makes in premium mode goes through the
4
+ ``cli-ai-generate`` Edge Function so the user's Lovarch credits are debited by
5
+ the canonical rule (1000 credits = $1 of API cost). See ``gateway.py``.
6
+ """
7
+ from lovarch_cli.ai.gateway import (
8
+ AiGatewayError,
9
+ AiImageResult,
10
+ AiTextResult,
11
+ InsufficientCreditsError,
12
+ LovarchAiGateway,
13
+ )
14
+
15
+ __all__ = [
16
+ "AiGatewayError",
17
+ "AiImageResult",
18
+ "AiTextResult",
19
+ "InsufficientCreditsError",
20
+ "LovarchAiGateway",
21
+ ]
@@ -0,0 +1,240 @@
1
+ """Client for the ``cli-ai-generate`` Edge Function.
2
+
3
+ This is the ONLY path the premium CLI (and the future MCP server) should use to
4
+ run paid image generation. It forces the call through the Lovarch platform,
5
+ which debits the authenticated user's credits by the 1000cr=$1 rule and refunds
6
+ on failure. The CLI must never call OpenAI/Mapbox/etc. directly in premium mode
7
+ — doing so bypasses the credit system.
8
+
9
+ Returns the generated image as raw bytes for the caller to persist (Fase 2 adds
10
+ server-side Storage persistence tied to the user's Lovarch account).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ from dataclasses import dataclass
16
+ from typing import Literal
17
+
18
+ from lovarch_cli.auth.session import LovarchSession
19
+
20
+ CLI_AI_GENERATE_PATH = "/functions/v1/cli-ai-generate"
21
+ CLI_AI_TEXT_PATH = "/functions/v1/cli-ai-text"
22
+ # Image generation with gpt-image-2 can take 10-60s; give it generous headroom.
23
+ _IMAGE_TIMEOUT = 200.0
24
+ _TEXT_TIMEOUT = 300.0
25
+
26
+ Quality = Literal["low", "medium", "high"]
27
+ Mode = Literal["generate", "edit"]
28
+ Role = Literal["executor", "verifier", "chief"]
29
+
30
+
31
+ class AiGatewayError(Exception):
32
+ """Generic failure calling cli-ai-generate."""
33
+
34
+
35
+ class InsufficientCreditsError(AiGatewayError):
36
+ """The user does not have enough credits for the requested operation."""
37
+
38
+ def __init__(self, available: int, needed: int) -> None:
39
+ self.available = available
40
+ self.needed = needed
41
+ super().__init__(
42
+ f"Crediti insufficienti: disponibili {available}, richiesti {needed}."
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class AiImageResult:
48
+ """Result of a successful image generation via the platform gateway.
49
+
50
+ Cost is expressed ONLY in the user's credits — provider amounts never
51
+ reach the client.
52
+ """
53
+
54
+ image_bytes: bytes
55
+ content_type: str
56
+ revised_prompt: str | None
57
+ credits_charged: int
58
+ balance: int | None
59
+ is_admin: bool
60
+
61
+
62
+ @dataclass
63
+ class AiTextResult:
64
+ """Result of a successful text generation via cli-ai-text."""
65
+
66
+ text: str
67
+ model: str
68
+ input_tokens: int
69
+ output_tokens: int
70
+ credits_charged: int
71
+ balance: int | None
72
+ is_admin: bool
73
+
74
+
75
+ def _decode_data_url(data_url: str) -> tuple[bytes, str]:
76
+ """Split a ``data:<mime>;base64,<payload>`` URL into (bytes, content_type)."""
77
+ if not data_url.startswith("data:"):
78
+ raise AiGatewayError("Risposta immagine non è un data URL.")
79
+ header, _, payload = data_url.partition(",")
80
+ if not payload:
81
+ raise AiGatewayError("Risposta immagine senza payload base64.")
82
+ content_type = header[len("data:") :].split(";")[0] or "image/png"
83
+ try:
84
+ return base64.b64decode(payload), content_type
85
+ except (ValueError, TypeError) as exc: # pragma: no cover - defensive
86
+ raise AiGatewayError(f"Base64 immagine non valido: {exc}") from exc
87
+
88
+
89
+ class LovarchAiGateway:
90
+ """Thin client over ``cli-ai-generate`` bound to a premium session."""
91
+
92
+ def __init__(self, session: LovarchSession) -> None:
93
+ self._session = session
94
+
95
+ async def generate_image(
96
+ self,
97
+ prompt: str,
98
+ *,
99
+ quality: Quality = "medium",
100
+ aspect: str = "1:1",
101
+ mode: Mode = "generate",
102
+ image_urls: list[str] | None = None,
103
+ operation_type: str | None = None,
104
+ ) -> AiImageResult:
105
+ """Generate (or edit) an image via the platform, debiting credits.
106
+
107
+ Raises ``InsufficientCreditsError`` on HTTP 402 and ``AiGatewayError``
108
+ on any other non-success response.
109
+ """
110
+ body: dict[str, object] = {
111
+ "mode": mode,
112
+ "prompt": prompt,
113
+ "quality": quality,
114
+ "aspect": aspect,
115
+ }
116
+ if image_urls:
117
+ body["image_urls"] = image_urls
118
+ if operation_type:
119
+ body["operation_type"] = operation_type
120
+
121
+ response = await self._session.request(
122
+ "POST", CLI_AI_GENERATE_PATH, json=body, timeout=_IMAGE_TIMEOUT
123
+ )
124
+
125
+ try:
126
+ data = response.json()
127
+ except ValueError:
128
+ data = {}
129
+
130
+ if response.status_code == 402:
131
+ raise InsufficientCreditsError(
132
+ available=int(data.get("credits_available", 0) or 0),
133
+ needed=int(data.get("credits_needed", 0) or 0),
134
+ )
135
+
136
+ if response.status_code != 200 or not data.get("ok"):
137
+ detail = data.get("error") if isinstance(data, dict) else None
138
+ raise AiGatewayError(
139
+ f"cli-ai-generate ha risposto {response.status_code}: {detail or 'errore sconosciuto'}"
140
+ )
141
+
142
+ image_bytes, content_type = _decode_data_url(str(data.get("image_base64", "")))
143
+ return AiImageResult(
144
+ image_bytes=image_bytes,
145
+ content_type=content_type,
146
+ revised_prompt=data.get("revised_prompt"),
147
+ credits_charged=int(data.get("credits_charged", 0) or 0),
148
+ balance=data.get("balance"),
149
+ is_admin=bool(data.get("is_admin", False)),
150
+ )
151
+
152
+ async def generate_text(
153
+ self,
154
+ prompt: str | None = None,
155
+ *,
156
+ messages: list[dict] | None = None,
157
+ role: Role = "executor",
158
+ model: str | None = None,
159
+ system: str | None = None,
160
+ max_tokens: int | None = None,
161
+ language: str | None = None,
162
+ operation_type: str | None = None,
163
+ ) -> AiTextResult:
164
+ """Generate text via cli-ai-text, debiting the user's credits.
165
+
166
+ Model selection is server-side: pass a ``role`` (executor|verifier|
167
+ chief) for the platform default, or an explicit ``model`` from the
168
+ platform catalog. ``language`` enforces strict output language.
169
+ """
170
+ body: dict[str, object] = {}
171
+ if model:
172
+ body["model"] = model
173
+ else:
174
+ body["role"] = role
175
+ if prompt:
176
+ body["prompt"] = prompt
177
+ if messages:
178
+ body["messages"] = messages
179
+ if system:
180
+ body["system"] = system
181
+ if max_tokens:
182
+ body["max_tokens"] = max_tokens
183
+ if language:
184
+ body["language"] = language
185
+ if operation_type:
186
+ body["operation_type"] = operation_type
187
+
188
+ response = await self._session.request(
189
+ "POST", CLI_AI_TEXT_PATH, json=body, timeout=_TEXT_TIMEOUT
190
+ )
191
+ try:
192
+ data = response.json()
193
+ except ValueError:
194
+ data = {}
195
+
196
+ if response.status_code == 402:
197
+ raise InsufficientCreditsError(
198
+ available=int(data.get("credits_available", 0) or 0),
199
+ needed=int(data.get("credits_needed", 0) or 0),
200
+ )
201
+ if response.status_code != 200 or not data.get("ok"):
202
+ detail = data.get("error") if isinstance(data, dict) else None
203
+ raise AiGatewayError(
204
+ f"cli-ai-text ha risposto {response.status_code}: {detail or 'errore sconosciuto'}"
205
+ )
206
+
207
+ usage = data.get("usage") or {}
208
+ return AiTextResult(
209
+ text=str(data.get("text", "")),
210
+ model=str(data.get("model", "")),
211
+ input_tokens=int(usage.get("input_tokens", 0) or 0),
212
+ output_tokens=int(usage.get("output_tokens", 0) or 0),
213
+ credits_charged=int(data.get("credits_charged", 0) or 0),
214
+ balance=data.get("balance"),
215
+ is_admin=bool(data.get("is_admin", False)),
216
+ )
217
+
218
+ async def get_user_context(self, lead_id: str | None = None) -> dict:
219
+ """Fetch the personalization bundle (cli-user-context).
220
+
221
+ Returns the raw bundle dict — brand, style, fiscal, signature_line,
222
+ preferences (incl. mandatory output language) and ``prompt_block``
223
+ ready to prepend to any agent prompt.
224
+ """
225
+ body: dict[str, object] = {}
226
+ if lead_id:
227
+ body["lead_id"] = lead_id
228
+ response = await self._session.request(
229
+ "POST", "/functions/v1/cli-user-context", json=body, timeout=60.0
230
+ )
231
+ try:
232
+ data = response.json()
233
+ except ValueError:
234
+ data = {}
235
+ if response.status_code != 200 or not data.get("ok"):
236
+ detail = data.get("error") if isinstance(data, dict) else None
237
+ raise AiGatewayError(
238
+ f"cli-user-context ha risposto {response.status_code}: {detail or 'errore sconosciuto'}"
239
+ )
240
+ return data
lovarch_cli/api.py ADDED
@@ -0,0 +1,111 @@
1
+ """lovarch-cli — HTTP client wrapper for Lovarch API.
2
+
3
+ Thin httpx wrapper that adds:
4
+ - Default API URL (overridable via LOVARCH_API_URL env)
5
+ - Anon key as default header (for public EFs like cli-signup)
6
+ - Bearer token injection for authenticated calls (premium)
7
+ - Error normalization to LovarchApiError
8
+ - Timeout defaults (30s connect, 120s read for long EFs)
9
+
10
+ For EF endpoints, the URL pattern is:
11
+ {API_URL}/functions/v1/{ef_name}
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ from lovarch_cli.config import DEFAULT_API_ANON_KEY, DEFAULT_API_URL
21
+ from lovarch_cli.i18n import t
22
+
23
+
24
+ class LovarchApiError(Exception):
25
+ """Normalized error from Lovarch API calls."""
26
+
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ status_code: int | None = None,
31
+ error_code: str | None = None,
32
+ payload: dict[str, Any] | None = None,
33
+ ) -> None:
34
+ super().__init__(message)
35
+ self.status_code = status_code
36
+ self.error_code = error_code
37
+ self.payload = payload or {}
38
+
39
+
40
+ @dataclass
41
+ class ApiClient:
42
+ """HTTP client for Lovarch Supabase APIs (REST + Edge Functions)."""
43
+
44
+ base_url: str = DEFAULT_API_URL
45
+ anon_key: str = DEFAULT_API_ANON_KEY
46
+ bearer_token: str | None = None
47
+ timeout_connect: float = 30.0
48
+ timeout_read: float = 120.0
49
+
50
+ def _headers(self) -> dict[str, str]:
51
+ headers = {
52
+ "Content-Type": "application/json",
53
+ "User-Agent": "lovarch-cli/0.1.0",
54
+ }
55
+ if self.anon_key:
56
+ headers["apikey"] = self.anon_key
57
+ if self.bearer_token:
58
+ headers["Authorization"] = f"Bearer {self.bearer_token}"
59
+ elif self.anon_key:
60
+ headers["Authorization"] = f"Bearer {self.anon_key}"
61
+ return headers
62
+
63
+ async def invoke_ef(
64
+ self,
65
+ function_name: str,
66
+ body: dict[str, Any],
67
+ bearer_token: str | None = None,
68
+ ) -> dict[str, Any]:
69
+ """Call a Supabase Edge Function and return its parsed JSON response.
70
+
71
+ Raises LovarchApiError on HTTP errors or `{ok: false}` body.
72
+ """
73
+ url = f"{self.base_url}/functions/v1/{function_name}"
74
+ headers = self._headers()
75
+ if bearer_token:
76
+ headers["Authorization"] = f"Bearer {bearer_token}"
77
+
78
+ timeout = httpx.Timeout(self.timeout_read, connect=self.timeout_connect)
79
+
80
+ try:
81
+ async with httpx.AsyncClient(timeout=timeout) as client:
82
+ response = await client.post(url, json=body, headers=headers)
83
+ except httpx.RequestError as exc:
84
+ msg = t("errors.network", function_name=function_name, exc=str(exc))
85
+ raise LovarchApiError(msg) from exc
86
+
87
+ try:
88
+ payload = response.json()
89
+ except ValueError as exc:
90
+ msg = t(
91
+ "errors.invalid_json",
92
+ function_name=function_name,
93
+ status_code=response.status_code,
94
+ snippet=response.text[:200],
95
+ )
96
+ raise LovarchApiError(msg, status_code=response.status_code) from exc
97
+
98
+ if response.status_code >= 400 or payload.get("ok") is False:
99
+ error_code = payload.get("error", "unknown_error")
100
+ # Server-localized message wins; otherwise build a localized fallback.
101
+ message = payload.get("message") or t(
102
+ "errors.unknown_api_error", error_code=error_code
103
+ )
104
+ raise LovarchApiError(
105
+ message=message,
106
+ status_code=response.status_code,
107
+ error_code=error_code,
108
+ payload=payload,
109
+ )
110
+
111
+ return payload
@@ -0,0 +1,32 @@
1
+ """lovarch-cli auth — premium PKCE flow + token storage.
2
+
3
+ Three modules:
4
+ - pkce.py → verifier/challenge generation (RFC 7636)
5
+ - local_server.py → ephemeral HTTP listener on 127.0.0.1:RANDOM/callback
6
+ - keyring_store.py → OS-native secure storage (macOS Keychain, Windows
7
+ Credential Manager, Linux Secret Service)
8
+
9
+ The flow (orchestrated in commands/login.py):
10
+ 1. Generate verifier + challenge via pkce.PkceParams.generate()
11
+ 2. Spin up local_server.AuthServer on 127.0.0.1:RANDOM_PORT
12
+ 3. Open browser to https://lovarch.com/cli-auth?...&redirect_uri=http://127.0.0.1:PORT/callback
13
+ 4. User authorizes on lovarch.com → web POSTs to cli-auth-store EF →
14
+ web redirects browser back to local server with ?code=X&state=Y
15
+ 5. Local server captures code+state, validates state, calls cli-auth-exchange
16
+ 6. Tokens returned → stored via keyring_store.save_premium_session()
17
+ """
18
+ from lovarch_cli.auth.keyring_store import (
19
+ PremiumSession,
20
+ clear_premium_session,
21
+ load_premium_session,
22
+ save_premium_session,
23
+ )
24
+ from lovarch_cli.auth.pkce import PkceParams
25
+
26
+ __all__ = [
27
+ "PkceParams",
28
+ "PremiumSession",
29
+ "clear_premium_session",
30
+ "load_premium_session",
31
+ "save_premium_session",
32
+ ]
@@ -0,0 +1,214 @@
1
+ """OS-native secure storage for premium Lovarch session tokens.
2
+
3
+ Uses the `keyring` lib (already in pyproject deps) which routes to:
4
+ - macOS → Keychain
5
+ - Windows → Windows Credential Manager
6
+ - Linux → Secret Service (gnome-keyring, KWallet, or fallback)
7
+
8
+ Service identifier: 'lovarch-cli'
9
+ Account identifiers (one per stored item):
10
+ - 'premium-access-token'
11
+ - 'premium-refresh-token'
12
+
13
+ The non-sensitive bits (user_id, email, full_name, expires_at) live in
14
+ ~/.lovarch/credentials.json alongside the free-mode payload, with
15
+ mode='premium'. Tokens never appear in plain JSON.
16
+
17
+ Fallback if keyring lib not available or backend fails: store tokens in
18
+ ~/.lovarch/credentials.json with chmod 0600 + a warning logged. This is
19
+ a soft fallback for headless/CI environments.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ try:
31
+ import keyring as _keyring
32
+ import keyring.errors as _keyring_errors
33
+
34
+ KEYRING_AVAILABLE = True
35
+ except ImportError:
36
+ KEYRING_AVAILABLE = False
37
+
38
+ from lovarch_cli.config import DEFAULT_HOME, Credentials, save_credentials
39
+
40
+ log = logging.getLogger("lovarch_cli.auth.keyring")
41
+
42
+ KEYRING_SERVICE = "lovarch-cli"
43
+ KEY_ACCESS = "premium-access-token"
44
+ KEY_REFRESH = "premium-refresh-token"
45
+
46
+
47
+ @dataclass
48
+ class PremiumSession:
49
+ """Loaded premium session — non-sensitive metadata + tokens."""
50
+
51
+ user_id: str
52
+ email: str
53
+ full_name: str | None
54
+ access_token: str
55
+ refresh_token: str
56
+ expires_at: str # ISO 8601
57
+ metadata: dict[str, Any] = field(default_factory=dict)
58
+
59
+
60
+ def _backend_available() -> bool:
61
+ if not KEYRING_AVAILABLE:
62
+ return False
63
+ try:
64
+ _keyring.get_password(KEYRING_SERVICE, KEY_ACCESS)
65
+ return True
66
+ except _keyring_errors.NoKeyringError:
67
+ return False
68
+ except Exception: # noqa: BLE001
69
+ return False
70
+
71
+
72
+ def save_premium_session(
73
+ *,
74
+ user_id: str,
75
+ email: str,
76
+ full_name: str | None,
77
+ access_token: str,
78
+ refresh_token: str,
79
+ expires_at: str,
80
+ language: str = "it",
81
+ home: Path | None = None,
82
+ ) -> tuple[bool, str]:
83
+ """Save tokens to OS keyring + non-sensitive credentials to JSON.
84
+
85
+ Returns (used_keyring, location_description) for UX messaging.
86
+ """
87
+ used_keyring = _backend_available()
88
+
89
+ if used_keyring:
90
+ try:
91
+ _keyring.set_password(
92
+ KEYRING_SERVICE, f"{KEY_ACCESS}:{user_id}", access_token
93
+ )
94
+ _keyring.set_password(
95
+ KEYRING_SERVICE, f"{KEY_REFRESH}:{user_id}", refresh_token
96
+ )
97
+ except Exception as exc: # noqa: BLE001
98
+ log.warning("keyring save failed (%s) — falling back to file", exc)
99
+ used_keyring = False
100
+
101
+ creds = Credentials(
102
+ mode="premium",
103
+ user_id=user_id,
104
+ email=email,
105
+ full_name=full_name,
106
+ language=language,
107
+ signed_up_at=datetime.utcnow().isoformat(timespec="seconds"),
108
+ metadata={
109
+ "premium_expires_at": expires_at,
110
+ "tokens_in_keyring": used_keyring,
111
+ # If keyring unavailable, store tokens in JSON as fallback (chmod 0600)
112
+ **(
113
+ {}
114
+ if used_keyring
115
+ else {
116
+ "fallback_access_token": access_token,
117
+ "fallback_refresh_token": refresh_token,
118
+ }
119
+ ),
120
+ },
121
+ )
122
+ creds_path = save_credentials(creds, home)
123
+
124
+ location = "OS keyring + " if used_keyring else "JSON fallback (chmod 0600): "
125
+ return used_keyring, f"{location}{creds_path}"
126
+
127
+
128
+ def load_premium_session(
129
+ home: Path | None = None,
130
+ ) -> PremiumSession | None:
131
+ """Load premium session from credentials.json + keyring.
132
+
133
+ Returns None if credentials.json is missing, mode != 'premium', or
134
+ keyring lookup fails AND no fallback is present.
135
+ """
136
+ creds_path = (home or DEFAULT_HOME) / "credentials.json"
137
+ if not creds_path.exists():
138
+ return None
139
+ try:
140
+ data: dict[str, Any] = json.loads(creds_path.read_text())
141
+ except (json.JSONDecodeError, ValueError):
142
+ return None
143
+ if data.get("mode") != "premium":
144
+ return None
145
+
146
+ user_id = data.get("user_id")
147
+ if not user_id:
148
+ return None
149
+
150
+ md = data.get("metadata") or {}
151
+ expires_at = md.get("premium_expires_at", "")
152
+
153
+ access_token: str | None = None
154
+ refresh_token: str | None = None
155
+
156
+ if md.get("tokens_in_keyring") and KEYRING_AVAILABLE:
157
+ try:
158
+ access_token = _keyring.get_password(
159
+ KEYRING_SERVICE, f"{KEY_ACCESS}:{user_id}"
160
+ )
161
+ refresh_token = _keyring.get_password(
162
+ KEYRING_SERVICE, f"{KEY_REFRESH}:{user_id}"
163
+ )
164
+ except Exception as exc: # noqa: BLE001
165
+ log.warning("keyring load failed (%s)", exc)
166
+
167
+ if not access_token or not refresh_token:
168
+ # Try fallback fields
169
+ access_token = md.get("fallback_access_token")
170
+ refresh_token = md.get("fallback_refresh_token")
171
+
172
+ if not access_token or not refresh_token:
173
+ return None
174
+
175
+ return PremiumSession(
176
+ user_id=user_id,
177
+ email=data.get("email", ""),
178
+ full_name=data.get("full_name"),
179
+ access_token=access_token,
180
+ refresh_token=refresh_token,
181
+ expires_at=expires_at,
182
+ metadata=md,
183
+ )
184
+
185
+
186
+ def clear_premium_session(home: Path | None = None) -> bool:
187
+ """Wipe premium tokens from keyring + delete credentials.json.
188
+
189
+ Returns True if anything was deleted.
190
+ """
191
+ creds_path = (home or DEFAULT_HOME) / "credentials.json"
192
+ user_id: str | None = None
193
+ if creds_path.exists():
194
+ try:
195
+ user_id = json.loads(creds_path.read_text()).get("user_id")
196
+ except (json.JSONDecodeError, ValueError):
197
+ pass
198
+
199
+ keyring_cleared = False
200
+ if user_id and KEYRING_AVAILABLE:
201
+ try:
202
+ _keyring.delete_password(KEYRING_SERVICE, f"{KEY_ACCESS}:{user_id}")
203
+ _keyring.delete_password(KEYRING_SERVICE, f"{KEY_REFRESH}:{user_id}")
204
+ keyring_cleared = True
205
+ except Exception: # noqa: BLE001
206
+ # Item may not exist — ignore
207
+ pass
208
+
209
+ file_cleared = False
210
+ if creds_path.exists():
211
+ creds_path.unlink()
212
+ file_cleared = True
213
+
214
+ return keyring_cleared or file_cleared