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