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.
- lovarch_cli/__init__.py +16 -0
- lovarch_cli/__main__.py +10 -0
- lovarch_cli/ai/__init__.py +21 -0
- lovarch_cli/ai/gateway.py +240 -0
- lovarch_cli/api.py +111 -0
- lovarch_cli/auth/__init__.py +32 -0
- lovarch_cli/auth/keyring_store.py +214 -0
- lovarch_cli/auth/local_server.py +165 -0
- lovarch_cli/auth/pkce.py +57 -0
- lovarch_cli/auth/session.py +189 -0
- lovarch_cli/cli.py +262 -0
- lovarch_cli/clients/__init__.py +33 -0
- lovarch_cli/clients/factory.py +54 -0
- lovarch_cli/clients/local_client.py +432 -0
- lovarch_cli/clients/lovarch_storage.py +174 -0
- lovarch_cli/clients/lovarch_supabase.py +295 -0
- lovarch_cli/clients/persistence.py +166 -0
- lovarch_cli/clients/storage.py +66 -0
- lovarch_cli/commands/__init__.py +10 -0
- lovarch_cli/commands/account.py +172 -0
- lovarch_cli/commands/audit.py +394 -0
- lovarch_cli/commands/config_cmd.py +80 -0
- lovarch_cli/commands/consolidate.py +217 -0
- lovarch_cli/commands/context_cmd.py +73 -0
- lovarch_cli/commands/dev.py +287 -0
- lovarch_cli/commands/do_cmd.py +120 -0
- lovarch_cli/commands/init.py +218 -0
- lovarch_cli/commands/jobs_cmd.py +95 -0
- lovarch_cli/commands/login.py +202 -0
- lovarch_cli/commands/mcp_cmd.py +26 -0
- lovarch_cli/commands/run.py +375 -0
- lovarch_cli/commands/signup.py +185 -0
- lovarch_cli/commands/status.py +243 -0
- lovarch_cli/commands/upgrade.py +108 -0
- lovarch_cli/commands/verifica_cmd.py +174 -0
- lovarch_cli/config.py +101 -0
- lovarch_cli/config_store.py +111 -0
- lovarch_cli/credits/__init__.py +35 -0
- lovarch_cli/credits/base.py +84 -0
- lovarch_cli/credits/factory.py +36 -0
- lovarch_cli/credits/local.py +34 -0
- lovarch_cli/credits/lovarch.py +56 -0
- lovarch_cli/i18n/__init__.py +27 -0
- lovarch_cli/i18n/loader.py +121 -0
- lovarch_cli/i18n/translations/en.json +168 -0
- lovarch_cli/i18n/translations/es.json +168 -0
- lovarch_cli/i18n/translations/it.json +168 -0
- lovarch_cli/i18n/translations/pt.json +168 -0
- lovarch_cli/mcp/__init__.py +9 -0
- lovarch_cli/mcp/server.py +199 -0
- lovarch_cli/mcp/tools.py +372 -0
- lovarch_cli/sample_downloader.py +255 -0
- lovarch_cli/squad/README.md +206 -0
- lovarch_cli/squad/agents/auditor-input.md +353 -0
- lovarch_cli/squad/agents/bim-engineer.md +404 -0
- lovarch_cli/squad/agents/briefing-architect.md +249 -0
- lovarch_cli/squad/agents/cad-engineer.md +278 -0
- lovarch_cli/squad/agents/capitolato-writer.md +256 -0
- lovarch_cli/squad/agents/computo-engineer.md +258 -0
- lovarch_cli/squad/agents/concept-designer.md +399 -0
- lovarch_cli/squad/agents/contratto-architect.md +243 -0
- lovarch_cli/squad/agents/deliverable-builder.md +253 -0
- lovarch_cli/squad/agents/energy-prelim.md +388 -0
- lovarch_cli/squad/agents/pratiche-it.md +251 -0
- lovarch_cli/squad/agents/progetto-chief.md +768 -0
- lovarch_cli/squad/agents/quality-dati.md +409 -0
- lovarch_cli/squad/agents/quality-misure.md +418 -0
- lovarch_cli/squad/agents/quality-normativa.md +417 -0
- lovarch_cli/squad/agents/quality-output.md +436 -0
- lovarch_cli/squad/agents/regolatorio-it.md +278 -0
- lovarch_cli/squad/checklists/handoff-quality-gate.md +232 -0
- lovarch_cli/squad/checklists/quality-dati-checklist.md +134 -0
- lovarch_cli/squad/checklists/quality-misure-checklist.md +139 -0
- lovarch_cli/squad/checklists/quality-normativa-checklist.md +121 -0
- lovarch_cli/squad/checklists/quality-output-checklist.md +116 -0
- lovarch_cli/squad/config.yaml +408 -0
- lovarch_cli/squad/data/CHANGELOG.md +272 -0
- lovarch_cli/squad/data/agents-prd.md +428 -0
- lovarch_cli/squad/data/architettura-progetto-rules.md +328 -0
- lovarch_cli/squad/data/handoff-card-template.md +231 -0
- lovarch_cli/squad/data/mocks/catasto-visura.json +72 -0
- lovarch_cli/squad/data/mocks/firma-envelope.json +43 -0
- lovarch_cli/squad/data/prezzario-lombardia-sample.json +312 -0
- lovarch_cli/squad/scripts/api_clients.py +206 -0
- lovarch_cli/squad/scripts/architect_profile.py +276 -0
- lovarch_cli/squad/scripts/deliverable_generators.py +844 -0
- lovarch_cli/squad/scripts/generate_attico_brera_dwg.py +369 -0
- lovarch_cli/squad/scripts/generate_chianti_dxf.py +368 -0
- lovarch_cli/squad/scripts/generate_chianti_images.py +223 -0
- lovarch_cli/squad/scripts/generate_real_sample_images.py +189 -0
- lovarch_cli/squad/scripts/generate_sample_assets.py +382 -0
- lovarch_cli/squad/scripts/lovarch_client.py +1046 -0
- lovarch_cli/squad/scripts/pipeline_runner.py +2095 -0
- lovarch_cli/squad/scripts/render_dxf_to_png.py +57 -0
- lovarch_cli/squad/scripts/run_palestra_demo.sh +277 -0
- lovarch_cli/squad/scripts/simulate_squad_execution.py +515 -0
- lovarch_cli/squad/scripts/validate-squad.py +383 -0
- lovarch_cli/squad/tasks/audit-input.md +146 -0
- lovarch_cli/squad/tasks/compute-metric.md +105 -0
- lovarch_cli/squad/tasks/consolidate-dossier.md +187 -0
- lovarch_cli/squad/tasks/generate-cad-plan.md +120 -0
- lovarch_cli/squad/tasks/generate-ifc-model.md +108 -0
- lovarch_cli/squad/tasks/write-capitolato.md +100 -0
- lovarch_cli/squad/templates/asseverazione-tecnica.md +126 -0
- lovarch_cli/squad/templates/capitolato-uni-11337.md +235 -0
- lovarch_cli/squad/templates/cila-comune-milano.md +177 -0
- lovarch_cli/squad/templates/contratto-cnappc.md +220 -0
- lovarch_cli/squad/workflows/dal-brief-al-cantiere.yaml +218 -0
- lovarch_cli/squad_loader.py +114 -0
- lovarch_cli/verify/__init__.py +15 -0
- lovarch_cli/verify/contratto.py +110 -0
- lovarch_cli/verify/dossier.py +97 -0
- lovarch_cli/verify/misure.py +83 -0
- lovarch_cli/verify/normativa.py +178 -0
- lovarch_cli/version.py +13 -0
- lovarch_cli/workflows/__init__.py +9 -0
- lovarch_cli/workflows/platform.py +212 -0
- lovarch_cli-0.2.1.dist-info/METADATA +232 -0
- lovarch_cli-0.2.1.dist-info/RECORD +122 -0
- lovarch_cli-0.2.1.dist-info/WHEEL +4 -0
- lovarch_cli-0.2.1.dist-info/entry_points.txt +3 -0
- lovarch_cli-0.2.1.dist-info/licenses/LICENSE +38 -0
lovarch_cli/__init__.py
ADDED
|
@@ -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__"]
|
lovarch_cli/__main__.py
ADDED
|
@@ -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
|