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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""User config store — ~/.lovarch/config.json.
|
|
2
|
+
|
|
3
|
+
Holds non-secret preferences (language, storage path) and, for FREE mode, the
|
|
4
|
+
student's own provider API keys (OpenAI, Mapbox) so `lovarch run` in free mode
|
|
5
|
+
can pick them up without exporting shell env vars. Premium mode never needs
|
|
6
|
+
these — paid AI is debited via the platform.
|
|
7
|
+
|
|
8
|
+
Secret values are stored chmod 0600 and masked when displayed.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from lovarch_cli.config import DEFAULT_HOME
|
|
19
|
+
|
|
20
|
+
# Allowed keys and whether each is secret (masked on display).
|
|
21
|
+
CONFIG_KEYS: dict[str, bool] = {
|
|
22
|
+
"language": False, # it | en | pt | es
|
|
23
|
+
"storage_path": False, # where free-mode projects live (default ~/.lovarch/projects)
|
|
24
|
+
"openai_key": True, # BYO key for free mode
|
|
25
|
+
"mapbox_token": True, # BYO token for free mode (geocoding)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_VALID_LANGS = {"it", "en", "pt", "es"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigError(Exception):
|
|
32
|
+
"""Invalid config key or value."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_path(home: Path | None = None) -> Path:
|
|
36
|
+
return (home or DEFAULT_HOME) / "config.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config(home: Path | None = None) -> dict[str, Any]:
|
|
40
|
+
path = config_path(home)
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return {}
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(path.read_text())
|
|
45
|
+
return data if isinstance(data, dict) else {}
|
|
46
|
+
except (json.JSONDecodeError, ValueError, OSError):
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _save_config(data: dict[str, Any], home: Path | None = None) -> Path:
|
|
51
|
+
path = config_path(home)
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
54
|
+
# Config may hold BYO API keys → keep it private.
|
|
55
|
+
try:
|
|
56
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
return path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _validate(key: str, value: str) -> str:
|
|
63
|
+
if key not in CONFIG_KEYS:
|
|
64
|
+
raise ConfigError(
|
|
65
|
+
f"Chiave sconosciuta: '{key}'. Valide: {', '.join(sorted(CONFIG_KEYS))}."
|
|
66
|
+
)
|
|
67
|
+
if key == "language" and value not in _VALID_LANGS:
|
|
68
|
+
raise ConfigError(f"Lingua non valida: '{value}'. Valide: it, en, pt, es.")
|
|
69
|
+
if not value:
|
|
70
|
+
raise ConfigError("Il valore non può essere vuoto (usa `unset` per rimuovere).")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_value(key: str, value: str, home: Path | None = None) -> None:
|
|
75
|
+
value = _validate(key, value)
|
|
76
|
+
data = load_config(home)
|
|
77
|
+
data[key] = value
|
|
78
|
+
_save_config(data, home)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_value(key: str, home: Path | None = None) -> Any:
|
|
82
|
+
if key not in CONFIG_KEYS:
|
|
83
|
+
raise ConfigError(f"Chiave sconosciuta: '{key}'.")
|
|
84
|
+
return load_config(home).get(key)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def unset_value(key: str, home: Path | None = None) -> bool:
|
|
88
|
+
if key not in CONFIG_KEYS:
|
|
89
|
+
raise ConfigError(f"Chiave sconosciuta: '{key}'.")
|
|
90
|
+
data = load_config(home)
|
|
91
|
+
if key in data:
|
|
92
|
+
del data[key]
|
|
93
|
+
_save_config(data, home)
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def mask(key: str, value: Any) -> str:
|
|
99
|
+
"""Mask secret values for display (show only the last 4 chars)."""
|
|
100
|
+
if value is None:
|
|
101
|
+
return "—"
|
|
102
|
+
text = str(value)
|
|
103
|
+
if CONFIG_KEYS.get(key) and len(text) > 4:
|
|
104
|
+
return "•" * (len(text) - 4) + text[-4:]
|
|
105
|
+
return text
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def display_items(home: Path | None = None) -> list[tuple[str, str, bool]]:
|
|
109
|
+
"""Return (key, masked_value, is_secret) for every known key."""
|
|
110
|
+
data = load_config(home)
|
|
111
|
+
return [(k, mask(k, data.get(k)), CONFIG_KEYS[k]) for k in CONFIG_KEYS]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Credits — pre-flight balance check for Premium pipelines.
|
|
2
|
+
|
|
3
|
+
Free mode users bring their own API keys (OpenAI, Mapbox, etc.) — they pay
|
|
4
|
+
their providers directly, so the CLI never debits credits. The Free client
|
|
5
|
+
is a no-op that always returns an "unlimited" balance.
|
|
6
|
+
|
|
7
|
+
Premium users pay via Lovarch credits. Before starting a long pipeline
|
|
8
|
+
(`arch run dal-brief-al-cantiere` consumes ~3,500 credits typical), we hit
|
|
9
|
+
the `cli-credits-check` Edge Function to verify the user has enough headroom
|
|
10
|
+
and abort EARLY with a clear message rather than failing mid-pipeline.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from lovarch_cli.credits import (
|
|
14
|
+
InsufficientCreditsError,
|
|
15
|
+
get_credits_client,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
client = get_credits_client(mode)
|
|
19
|
+
balance = await client.check(required=3500)
|
|
20
|
+
if not balance.sufficient:
|
|
21
|
+
raise InsufficientCreditsError(balance)
|
|
22
|
+
"""
|
|
23
|
+
from lovarch_cli.credits.base import (
|
|
24
|
+
CreditsBalance,
|
|
25
|
+
CreditsClient,
|
|
26
|
+
InsufficientCreditsError,
|
|
27
|
+
)
|
|
28
|
+
from lovarch_cli.credits.factory import get_credits_client
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CreditsBalance",
|
|
32
|
+
"CreditsClient",
|
|
33
|
+
"InsufficientCreditsError",
|
|
34
|
+
"get_credits_client",
|
|
35
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Credits — abstract base + value types."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class CreditsBalance:
|
|
10
|
+
"""Snapshot of a user's credit standing at a point in time.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
balance: total credits granted (monthly + bonus).
|
|
14
|
+
monthly_used: credits consumed since the start of the cycle.
|
|
15
|
+
credits_remaining: max(0, balance - monthly_used).
|
|
16
|
+
is_admin: admins bypass credit gates entirely.
|
|
17
|
+
required: threshold the caller asked us to compare against, or None.
|
|
18
|
+
sufficient: is_admin OR credits_remaining >= required (True when
|
|
19
|
+
required is None — i.e. caller just wants the balance).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
balance: int
|
|
23
|
+
monthly_used: int
|
|
24
|
+
credits_remaining: int
|
|
25
|
+
is_admin: bool
|
|
26
|
+
required: int | None
|
|
27
|
+
sufficient: bool
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def deficit(self) -> int:
|
|
31
|
+
"""How many extra credits the user needs to cross `required`.
|
|
32
|
+
|
|
33
|
+
Returns 0 when sufficient or when no required threshold was given.
|
|
34
|
+
"""
|
|
35
|
+
if self.sufficient or self.required is None:
|
|
36
|
+
return 0
|
|
37
|
+
return max(0, self.required - self.credits_remaining)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InsufficientCreditsError(Exception):
|
|
41
|
+
"""Raised when a pipeline cannot proceed because of credit shortage.
|
|
42
|
+
|
|
43
|
+
Carries the full balance so callers can render rich error UI.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, balance: CreditsBalance) -> None:
|
|
47
|
+
self.balance = balance
|
|
48
|
+
super().__init__(
|
|
49
|
+
f"Insufficient credits: have {balance.credits_remaining}, "
|
|
50
|
+
f"need {balance.required} (deficit: {balance.deficit})"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CreditsClient(ABC):
|
|
55
|
+
"""Backend-agnostic credits interface.
|
|
56
|
+
|
|
57
|
+
Free mode returns an unlimited no-op; Premium calls cli-credits-check.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def check(self, required: int | None = None) -> CreditsBalance:
|
|
62
|
+
"""Fetch the user's current balance.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
required: if given, server compares and sets `sufficient`.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
CreditsBalance snapshot.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
RuntimeError: backend communication failure.
|
|
72
|
+
"""
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
|
|
75
|
+
async def check_or_raise(self, required: int) -> CreditsBalance:
|
|
76
|
+
"""Fetch balance and raise InsufficientCreditsError if not sufficient.
|
|
77
|
+
|
|
78
|
+
Convenience wrapper around `check()` for callers that just want to
|
|
79
|
+
gate an operation.
|
|
80
|
+
"""
|
|
81
|
+
balance = await self.check(required=required)
|
|
82
|
+
if not balance.sufficient:
|
|
83
|
+
raise InsufficientCreditsError(balance)
|
|
84
|
+
return balance
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Credits — backend factory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from lovarch_cli.clients.persistence import ExecutionMode
|
|
5
|
+
from lovarch_cli.credits.base import CreditsClient
|
|
6
|
+
from lovarch_cli.credits.local import FreeCreditsClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_credits_client(mode: ExecutionMode) -> CreditsClient:
|
|
10
|
+
"""Return the credits client matching the execution mode.
|
|
11
|
+
|
|
12
|
+
Free mode returns a no-op client (always sufficient).
|
|
13
|
+
Premium mode loads the keyring session and returns LovarchCreditsClient.
|
|
14
|
+
|
|
15
|
+
Raises:
|
|
16
|
+
RuntimeError: premium requested but no session in keyring.
|
|
17
|
+
"""
|
|
18
|
+
if mode == ExecutionMode.FREE:
|
|
19
|
+
return FreeCreditsClient()
|
|
20
|
+
|
|
21
|
+
if mode == ExecutionMode.PREMIUM:
|
|
22
|
+
# Lazy imports — Free flows shouldn't pay for httpx + Supabase modules.
|
|
23
|
+
from lovarch_cli.auth.session import LovarchSession
|
|
24
|
+
from lovarch_cli.credits.lovarch import LovarchCreditsClient
|
|
25
|
+
|
|
26
|
+
session = LovarchSession.load()
|
|
27
|
+
if session is None:
|
|
28
|
+
msg = (
|
|
29
|
+
"Premium credits check requires authentication. Run "
|
|
30
|
+
"'lovarch login --premium' first."
|
|
31
|
+
)
|
|
32
|
+
raise RuntimeError(msg)
|
|
33
|
+
return LovarchCreditsClient(session)
|
|
34
|
+
|
|
35
|
+
msg = f"Unknown execution mode: {mode}"
|
|
36
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Credits — Free mode no-op implementation.
|
|
2
|
+
|
|
3
|
+
Free users bring their own API keys; the CLI never debits Lovarch credits.
|
|
4
|
+
This client always reports an unlimited balance with `sufficient=True`.
|
|
5
|
+
|
|
6
|
+
Kept separate from base.py so the abstract module stays import-light and
|
|
7
|
+
free users don't accidentally pay the cost of importing httpx.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from lovarch_cli.credits.base import CreditsBalance, CreditsClient
|
|
12
|
+
|
|
13
|
+
# Sentinel used as the "unlimited" balance value. Picked arbitrarily large
|
|
14
|
+
# (2^31 - 1) so any reasonable required threshold passes the check.
|
|
15
|
+
_UNLIMITED = 2_147_483_647
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FreeCreditsClient(CreditsClient):
|
|
19
|
+
"""No-op client — always reports unlimited credits.
|
|
20
|
+
|
|
21
|
+
Free users supply their own provider keys (OpenAI, Mapbox), so the CLI
|
|
22
|
+
has no balance to track. We still return a CreditsBalance shape so
|
|
23
|
+
callers don't need to special-case Free vs Premium.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def check(self, required: int | None = None) -> CreditsBalance:
|
|
27
|
+
return CreditsBalance(
|
|
28
|
+
balance=_UNLIMITED,
|
|
29
|
+
monthly_used=0,
|
|
30
|
+
credits_remaining=_UNLIMITED,
|
|
31
|
+
is_admin=False,
|
|
32
|
+
required=required,
|
|
33
|
+
sufficient=True,
|
|
34
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Credits — Premium mode implementation backed by cli-credits-check EF."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from lovarch_cli.credits.base import CreditsBalance, CreditsClient
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from lovarch_cli.auth.session import LovarchSession
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LovarchCreditsClient(CreditsClient):
|
|
13
|
+
"""Calls the `cli-credits-check` Edge Function with the premium Bearer.
|
|
14
|
+
|
|
15
|
+
Uses LovarchSession's auto-refresh-on-401 request method, so a near-
|
|
16
|
+
expired access_token is handled transparently.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, session: "LovarchSession") -> None:
|
|
20
|
+
self._session = session
|
|
21
|
+
|
|
22
|
+
async def check(self, required: int | None = None) -> CreditsBalance:
|
|
23
|
+
body: dict[str, int] = {}
|
|
24
|
+
if required is not None:
|
|
25
|
+
body["required"] = int(required)
|
|
26
|
+
|
|
27
|
+
# session.request normalizes URL + injects Bearer + handles 401 refresh.
|
|
28
|
+
# cli-credits-check is at /functions/v1/cli-credits-check.
|
|
29
|
+
path = "/functions/v1/cli-credits-check"
|
|
30
|
+
response = await self._session.request("POST", path, json=body)
|
|
31
|
+
|
|
32
|
+
if response.status_code != 200:
|
|
33
|
+
# Try to surface server-localized message when present.
|
|
34
|
+
try:
|
|
35
|
+
payload = response.json()
|
|
36
|
+
except ValueError:
|
|
37
|
+
payload = {}
|
|
38
|
+
msg = payload.get("message") or response.text[:200]
|
|
39
|
+
raise RuntimeError(
|
|
40
|
+
f"cli-credits-check failed (HTTP {response.status_code}): {msg}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
data = response.json()
|
|
44
|
+
if data.get("ok") is not True:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
f"cli-credits-check error: {data.get('error', 'unknown')}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return CreditsBalance(
|
|
50
|
+
balance=int(data.get("balance", 0)),
|
|
51
|
+
monthly_used=int(data.get("monthly_used", 0)),
|
|
52
|
+
credits_remaining=int(data.get("credits_remaining", 0)),
|
|
53
|
+
is_admin=bool(data.get("is_admin", False)),
|
|
54
|
+
required=data.get("required"),
|
|
55
|
+
sufficient=bool(data.get("sufficient", False)),
|
|
56
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""lovarch-cli i18n — bundled translations + lookup loader.
|
|
2
|
+
|
|
3
|
+
4 languages supported (CLAUDE.md MANDATORY: 4 langs always):
|
|
4
|
+
- it (Italiano) — DEFAULT
|
|
5
|
+
- pt (Português)
|
|
6
|
+
- en (English)
|
|
7
|
+
- es (Español)
|
|
8
|
+
|
|
9
|
+
Usage in subcommands:
|
|
10
|
+
|
|
11
|
+
from lovarch_cli.i18n import t, current_lang
|
|
12
|
+
|
|
13
|
+
print(t("signup.welcome_title"))
|
|
14
|
+
print(t("signup.invalid_email", lang="pt"))
|
|
15
|
+
|
|
16
|
+
Detection chain (in current_lang()):
|
|
17
|
+
1. Explicit override via set_current_lang(...) (set by --lang flag in cli.py)
|
|
18
|
+
2. LOVARCH_LANG env var
|
|
19
|
+
3. LANG env var (e.g. 'it_IT.UTF-8' → 'it')
|
|
20
|
+
4. Default: 'it'
|
|
21
|
+
|
|
22
|
+
Fallback chain on missing key:
|
|
23
|
+
requested_lang → 'it' → 'en' → key itself (returned as-is for visibility)
|
|
24
|
+
"""
|
|
25
|
+
from lovarch_cli.i18n.loader import current_lang, set_current_lang, t
|
|
26
|
+
|
|
27
|
+
__all__ = ["t", "current_lang", "set_current_lang"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""i18n string loader — reads bundled JSON translations and provides t()."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("lovarch_cli.i18n")
|
|
12
|
+
|
|
13
|
+
VALID_LANGUAGES: tuple[str, ...] = ("it", "pt", "en", "es")
|
|
14
|
+
DEFAULT_LANGUAGE = "it"
|
|
15
|
+
FALLBACK_CHAIN = ("it", "en") # tried in order if key missing in requested lang
|
|
16
|
+
|
|
17
|
+
_TRANSLATIONS_DIR = Path(__file__).parent / "translations"
|
|
18
|
+
|
|
19
|
+
# Module-level mutable holder for the active language. Set by cli.py at
|
|
20
|
+
# startup via set_current_lang() based on the --lang flag.
|
|
21
|
+
_current_lang: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=4)
|
|
25
|
+
def _load_translations(lang: str) -> dict[str, Any]:
|
|
26
|
+
"""Load a translations JSON from bundled package data."""
|
|
27
|
+
path = _TRANSLATIONS_DIR / f"{lang}.json"
|
|
28
|
+
if not path.exists():
|
|
29
|
+
log.warning("i18n: translations file missing: %s", path)
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
34
|
+
log.warning("i18n: failed to load %s: %s", path, exc)
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _detect_from_env() -> str:
|
|
39
|
+
"""Detect language from environment variables only."""
|
|
40
|
+
arch_lang = os.environ.get("LOVARCH_LANG", "").lower().strip()
|
|
41
|
+
if arch_lang in VALID_LANGUAGES:
|
|
42
|
+
return arch_lang
|
|
43
|
+
sys_lang = os.environ.get("LANG", "").split("_")[0].lower()
|
|
44
|
+
if sys_lang in VALID_LANGUAGES:
|
|
45
|
+
return sys_lang
|
|
46
|
+
return DEFAULT_LANGUAGE
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def current_lang() -> str:
|
|
50
|
+
"""Return the active language for translation lookups."""
|
|
51
|
+
global _current_lang
|
|
52
|
+
if _current_lang is None:
|
|
53
|
+
_current_lang = _detect_from_env()
|
|
54
|
+
return _current_lang
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_current_lang(lang: str | None) -> str:
|
|
58
|
+
"""Override the active language (used by --lang flag at CLI start).
|
|
59
|
+
|
|
60
|
+
Pass None to reset to env-based detection on next current_lang() call.
|
|
61
|
+
Returns the resolved language (after validation/normalization).
|
|
62
|
+
"""
|
|
63
|
+
global _current_lang
|
|
64
|
+
if lang is None:
|
|
65
|
+
_current_lang = None
|
|
66
|
+
return current_lang()
|
|
67
|
+
normalized = lang.lower().strip()
|
|
68
|
+
if normalized not in VALID_LANGUAGES:
|
|
69
|
+
log.warning(
|
|
70
|
+
"i18n: invalid lang override %r — falling back to env detection",
|
|
71
|
+
lang,
|
|
72
|
+
)
|
|
73
|
+
_current_lang = None
|
|
74
|
+
return current_lang()
|
|
75
|
+
_current_lang = normalized
|
|
76
|
+
return normalized
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _lookup(key: str, lang: str) -> str | None:
|
|
80
|
+
"""Walk dotted key (e.g. 'signup.welcome_title') in nested dict."""
|
|
81
|
+
data = _load_translations(lang)
|
|
82
|
+
parts = key.split(".")
|
|
83
|
+
node: Any = data
|
|
84
|
+
for part in parts:
|
|
85
|
+
if not isinstance(node, dict) or part not in node:
|
|
86
|
+
return None
|
|
87
|
+
node = node[part]
|
|
88
|
+
if isinstance(node, str):
|
|
89
|
+
return node
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def t(key: str, lang: str | None = None, **vars: Any) -> str:
|
|
94
|
+
"""Translate a key to the target language with fallback chain.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
key: dotted key path (e.g. 'signup.welcome_title')
|
|
98
|
+
lang: optional override; defaults to current_lang()
|
|
99
|
+
vars: optional Python-format vars (e.g. t('cli.greet', name='Pablo'))
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Translated string, or the key itself if missing in all fallback langs
|
|
103
|
+
(so the developer can spot missing translations visually).
|
|
104
|
+
"""
|
|
105
|
+
target = lang or current_lang()
|
|
106
|
+
chain = (target, *(L for L in FALLBACK_CHAIN if L != target))
|
|
107
|
+
value: str | None = None
|
|
108
|
+
for candidate in chain:
|
|
109
|
+
value = _lookup(key, candidate)
|
|
110
|
+
if value is not None:
|
|
111
|
+
break
|
|
112
|
+
if value is None:
|
|
113
|
+
log.warning("i18n: missing key %r in all langs %s", key, chain)
|
|
114
|
+
return key
|
|
115
|
+
if vars:
|
|
116
|
+
try:
|
|
117
|
+
return value.format(**vars)
|
|
118
|
+
except (KeyError, IndexError) as exc:
|
|
119
|
+
log.warning("i18n: format error for key %r vars %r: %s", key, vars, exc)
|
|
120
|
+
return value
|
|
121
|
+
return value
|