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,375 @@
1
+ """lovarch run — Execute the squad pipeline against a project.
2
+
3
+ Pragmatic shell-out: invokes the existing `pipeline_runner.py` (1821 lines,
4
+ proven in production) as a subprocess instead of pulling its logic into the
5
+ CLI's modular phases architecture (that's Story 1.3 — deferred to Q3 2026).
6
+
7
+ Flow:
8
+ 1. Resolve project dir (~/.lovarch/projects/<name>/)
9
+ 2. Verify audit was run and passed (last_audit.verdict in project.yaml)
10
+ 3. Load credentials → detect mode (free/premium)
11
+ 4. Premium: pre-flight credits.check_or_raise(required=3500)
12
+ Free: skip credits gate
13
+ 5. Persistence: create_execution() → exec_id
14
+ 6. Locate bundled pipeline_runner.py inside lovarch_cli/squad/
15
+ 7. subprocess.Popen with --input-dir <project_input> + mode flag
16
+ Free → --dry-run (simulation, no API calls)
17
+ Premium → --real (production, hits Lovarch Supabase + AI providers)
18
+ 8. Stream stdout/stderr to user in real time (preserve ANSI colors)
19
+ 9. Wait for exit code → persistence.update_execution(status)
20
+ 10. Show summary panel with `lovarch status` hint
21
+
22
+ Free mode dry-run is intentionally limited: the existing pipeline_runner
23
+ hardcodes LovarchClient() which connects to Supabase. A "true" Free run
24
+ that uses LocalSqliteClient + LocalFilesystemStorage requires the Story 1.3
25
+ refactor (replace LovarchClient calls with persistence ABC). For the curso
26
+ delivery, Free → dry-run is sufficient (shows the orchestration flow,
27
+ useful for teaching), Premium → real run delivers actual artifacts.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Annotated
38
+
39
+ import typer
40
+ import yaml
41
+ from rich.console import Console
42
+ from rich.panel import Panel
43
+ from rich.text import Text
44
+
45
+ from lovarch_cli.clients.persistence import ExecutionMode
46
+ from lovarch_cli.config import DEFAULT_HOME, load_credentials
47
+ from lovarch_cli.credits import (
48
+ InsufficientCreditsError,
49
+ get_credits_client,
50
+ )
51
+ from lovarch_cli.i18n import current_lang, set_current_lang, t
52
+
53
+ console = Console()
54
+ err_console = Console(stderr=True)
55
+
56
+ # Typical workflow cost (mirrors what the squad bills for a full run).
57
+ # This is the threshold that gates premium runs at pre-flight.
58
+ DEFAULT_REQUIRED_CREDITS = 3_500
59
+
60
+ # Default workflow until we expose more squads via `lovarch run --workflow X`.
61
+ DEFAULT_WORKFLOW = "dal-brief-al-cantiere"
62
+
63
+
64
+ def _pipeline_runner_path(squad_root: Path) -> Path:
65
+ """Locate pipeline_runner.py inside the resolved squad root."""
66
+ return squad_root / "scripts" / "pipeline_runner.py"
67
+
68
+
69
+ def _resolve_mode_from_creds() -> ExecutionMode:
70
+ """Map persisted credentials.mode → ExecutionMode enum."""
71
+ creds = load_credentials()
72
+ if creds.mode == "premium":
73
+ return ExecutionMode.PREMIUM
74
+ # 'free' or 'none' → Free mode dry-run (no real calls)
75
+ return ExecutionMode.FREE
76
+
77
+
78
+ def _read_audit_verdict(project_dir: Path) -> str | None:
79
+ """Return the last_audit.verdict from project.yaml, or None if absent."""
80
+ yaml_path = project_dir / "project.yaml"
81
+ if not yaml_path.exists():
82
+ return None
83
+ try:
84
+ meta = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
85
+ except yaml.YAMLError:
86
+ return None
87
+ last = meta.get("last_audit") or {}
88
+ return last.get("verdict")
89
+
90
+
91
+ # Map pipeline_runner.py exit codes to a terminal run status. Kept in sync with
92
+ # the EXIT_* constants in squads/architettura-progetto/scripts/pipeline_runner.py.
93
+ _RUN_EXIT_STATUS = {
94
+ 0: "completed",
95
+ 3: "qa_rejected", # Tier 2 QA REJECT — NOT a crash, NOT a success
96
+ }
97
+
98
+
99
+ def _status_from_returncode(returncode: int) -> str:
100
+ """Translate the runner's exit code into a terminal run status."""
101
+ return _RUN_EXIT_STATUS.get(returncode, "failed")
102
+
103
+
104
+ def _write_last_run(project_dir: Path, status: str, returncode: int) -> None:
105
+ """Persist the terminal run status into project.yaml `last_run`.
106
+
107
+ Best-effort: a write failure must never change the run's exit code.
108
+ """
109
+ yaml_path = project_dir / "project.yaml"
110
+ try:
111
+ meta = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
112
+ except (OSError, yaml.YAMLError):
113
+ meta = {}
114
+ meta["last_run"] = {
115
+ "status": status,
116
+ "exit_code": returncode,
117
+ "at": datetime.now(timezone.utc).isoformat(),
118
+ }
119
+ try:
120
+ yaml_path.write_text(
121
+ yaml.safe_dump(meta, sort_keys=False, allow_unicode=True),
122
+ encoding="utf-8",
123
+ )
124
+ except OSError:
125
+ pass
126
+
127
+
128
+ def run_command(
129
+ project: Annotated[
130
+ str,
131
+ typer.Argument(help="Project name (created via `lovarch init`)."),
132
+ ],
133
+ workflow: Annotated[
134
+ str,
135
+ typer.Option(
136
+ "--workflow",
137
+ "-w",
138
+ help="Workflow to execute.",
139
+ ),
140
+ ] = DEFAULT_WORKFLOW,
141
+ skip_audit: Annotated[
142
+ bool,
143
+ typer.Option(
144
+ "--skip-audit",
145
+ help="Skip the audit-verdict check (dangerous).",
146
+ ),
147
+ ] = False,
148
+ skip_credits: Annotated[
149
+ bool,
150
+ typer.Option(
151
+ "--skip-credits",
152
+ help="Skip the credits pre-flight (Premium only — for testing).",
153
+ ),
154
+ ] = False,
155
+ force_dry_run: Annotated[
156
+ bool,
157
+ typer.Option(
158
+ "--dry-run",
159
+ help="Force dry-run mode regardless of free/premium auth.",
160
+ ),
161
+ ] = False,
162
+ lang_flag: Annotated[
163
+ str | None,
164
+ typer.Option("--lang", "-l", help="Override language (it/pt/en/es)."),
165
+ ] = None,
166
+ home_override: Annotated[
167
+ Path | None,
168
+ typer.Option(
169
+ "--home",
170
+ help="Override $HOME/.lovarch root (mainly for tests).",
171
+ hidden=True,
172
+ ),
173
+ ] = None,
174
+ squad_src: Annotated[
175
+ Path | None,
176
+ typer.Option(
177
+ "--squad-src",
178
+ help=(
179
+ "Path to a squad-architettura-progetto source dir to use "
180
+ "instead of the bundled payload. Also reads $LOVARCH_SQUAD_SRC. "
181
+ "Use for the developer's edit-and-test loop against the "
182
+ "monorepo Lovarch squad sources."
183
+ ),
184
+ ),
185
+ ] = None,
186
+ ) -> None:
187
+ """Run the squad pipeline against a project."""
188
+ if lang_flag is not None:
189
+ set_current_lang(lang_flag)
190
+ lang = current_lang()
191
+
192
+ home = home_override or DEFAULT_HOME
193
+ proj_dir = home / "projects" / project
194
+ if not proj_dir.is_dir():
195
+ err_console.print(
196
+ f"\n[red]✗ {t('run.no_project', lang=lang, name=project)}[/red]\n"
197
+ )
198
+ sys.exit(2)
199
+
200
+ input_dir = proj_dir / "input"
201
+ if not input_dir.is_dir():
202
+ err_console.print(
203
+ f"\n[red]✗ {t('run.no_input', lang=lang, path=str(input_dir))}[/red]\n"
204
+ )
205
+ sys.exit(2)
206
+
207
+ # ─── Pre-flight: audit verdict gate ──────────────────────────────────
208
+ if not skip_audit:
209
+ verdict = _read_audit_verdict(proj_dir)
210
+ if verdict is None:
211
+ err_console.print(
212
+ f"\n[yellow]{t('run.no_audit', lang=lang, name=project)}[/yellow]\n"
213
+ )
214
+ sys.exit(1)
215
+ if verdict == "FAIL":
216
+ err_console.print(
217
+ f"\n[red]{t('run.audit_failed', lang=lang, name=project)}[/red]\n"
218
+ )
219
+ sys.exit(1)
220
+
221
+ # ─── Mode resolution ─────────────────────────────────────────────────
222
+ mode = ExecutionMode.FREE if force_dry_run else _resolve_mode_from_creds()
223
+ is_dry_run = (mode == ExecutionMode.FREE) or force_dry_run
224
+
225
+ # ─── Pre-flight: credits check (Premium only) ────────────────────────
226
+ if mode == ExecutionMode.PREMIUM and not skip_credits:
227
+ try:
228
+ credits_client = get_credits_client(mode)
229
+ asyncio.run(
230
+ credits_client.check_or_raise(required=DEFAULT_REQUIRED_CREDITS)
231
+ )
232
+ except InsufficientCreditsError as exc:
233
+ bal = exc.balance
234
+ err_console.print(
235
+ "\n[red]"
236
+ + t(
237
+ "errors.credits_insufficient",
238
+ lang=lang,
239
+ remaining=bal.credits_remaining,
240
+ required=bal.required,
241
+ deficit=bal.deficit,
242
+ )
243
+ + "[/red]\n"
244
+ )
245
+ sys.exit(1)
246
+ except RuntimeError as exc:
247
+ # Premium client unavailable (no keyring session). Programmer-facing.
248
+ err_console.print(
249
+ f"\n[red]{t('errors.credits_check_failed', lang=lang, message=str(exc))}[/red]\n"
250
+ )
251
+ sys.exit(1)
252
+
253
+ # ─── Resolve squad payload + locate pipeline_runner.py ──────────────
254
+ from lovarch_cli.squad_loader import (
255
+ SquadNotFoundError,
256
+ resolve_squad_root,
257
+ squad_source_label,
258
+ )
259
+
260
+ try:
261
+ squad_root = resolve_squad_root(override=squad_src)
262
+ except SquadNotFoundError as exc:
263
+ err_console.print(f"\n[red]✗ {exc}[/red]\n")
264
+ sys.exit(2)
265
+
266
+ runner = _pipeline_runner_path(squad_root)
267
+ if not runner.exists():
268
+ err_console.print(
269
+ f"\n[red]✗ {t('run.no_runner', lang=lang, path=str(runner))}[/red]\n"
270
+ )
271
+ sys.exit(2)
272
+
273
+ # Show which squad source is in use (helpful during dev loop).
274
+ src_label = squad_source_label(squad_root)
275
+ if src_label != "bundled":
276
+ console.print(
277
+ f"[dim cyan]↳ squad: {squad_root} ({src_label})[/dim cyan]"
278
+ )
279
+
280
+ # ─── Header panel ────────────────────────────────────────────────────
281
+ mode_label = t(
282
+ "run.mode_free" if is_dry_run else "run.mode_premium",
283
+ lang=lang,
284
+ )
285
+ body_lines = [
286
+ t("run.starting", lang=lang, project=project, workflow=workflow),
287
+ t("run.mode_label", lang=lang, mode=mode_label),
288
+ ]
289
+ if is_dry_run:
290
+ body_lines.append(t("run.dry_run_note", lang=lang))
291
+ console.print()
292
+ console.print(
293
+ Panel(
294
+ Text.from_markup("\n".join(body_lines)),
295
+ title=f"[bold gold1]🚀 {t('run.title', lang=lang)}[/bold gold1]",
296
+ border_style="gold1",
297
+ padding=(1, 2),
298
+ )
299
+ )
300
+ console.print()
301
+
302
+ # ─── Build subprocess command ────────────────────────────────────────
303
+ cmd = [
304
+ sys.executable,
305
+ str(runner),
306
+ "--input-dir",
307
+ str(input_dir),
308
+ ]
309
+ cmd.append("--dry-run" if is_dry_run else "--real")
310
+
311
+ # Env vars passed through. The pipeline_runner reads OPENAI_API_KEY,
312
+ # MAPBOX_TOKEN, etc. directly from os.environ — so any keys the user
313
+ # has exported in their shell are inherited.
314
+ env = dict(os.environ)
315
+ env.setdefault("LOVARCH_PROJECT_NAME", project)
316
+ env.setdefault("LOVARCH_WORKFLOW", workflow)
317
+
318
+ # PREMIUM: hand the user's Supabase session to the runner so ALL paid AI
319
+ # goes through cli-ai-generate (debiting the user's credits, 1000cr=$1) and
320
+ # persistence writes as the user (RLS) — the runner never touches the
321
+ # student's OPENAI_API_KEY nor a service_role key. Also pass --user-id so the
322
+ # execution rows are owned by the token's user (RLS requires it).
323
+ if mode == ExecutionMode.PREMIUM and not is_dry_run:
324
+ from lovarch_cli.auth.session import LovarchSession
325
+ from lovarch_cli.config import DEFAULT_API_ANON_KEY, DEFAULT_API_URL
326
+
327
+ session = LovarchSession.load()
328
+ if session is not None:
329
+ env["LOVARCH_ACCESS_TOKEN"] = session.access_token
330
+ env["LOVARCH_ANON_KEY"] = DEFAULT_API_ANON_KEY
331
+ env["LOVARCH_API_URL"] = DEFAULT_API_URL
332
+ env["LOVARCH_SUPABASE_URL"] = DEFAULT_API_URL
333
+ cmd += ["--user-id", session.user_id]
334
+
335
+ # ─── Run subprocess + stream output ──────────────────────────────────
336
+ try:
337
+ result = subprocess.run(cmd, env=env, check=False)
338
+ except FileNotFoundError as exc:
339
+ err_console.print(
340
+ f"\n[red]✗ {t('run.subprocess_failed', lang=lang, message=str(exc))}[/red]\n"
341
+ )
342
+ sys.exit(2)
343
+ except KeyboardInterrupt:
344
+ err_console.print(
345
+ f"\n[yellow]⚠ {t('run.interrupted', lang=lang)}[/yellow]\n"
346
+ )
347
+ sys.exit(130)
348
+
349
+ # ─── Result panel ────────────────────────────────────────────────────
350
+ # Terminal status is derived from the runner's exit code:
351
+ # 0 → completed · 3 → qa_rejected (Tier 2 QA REJECT) · other → failed
352
+ run_status = _status_from_returncode(result.returncode)
353
+ _write_last_run(proj_dir, run_status, result.returncode)
354
+
355
+ if run_status == "completed":
356
+ summary_key, border = "run.completed", "green"
357
+ elif run_status == "qa_rejected":
358
+ summary_key, border = "run.qa_rejected", "yellow"
359
+ else:
360
+ summary_key, border = "run.failed", "red"
361
+
362
+ console.print()
363
+ console.print(
364
+ Panel(
365
+ Text.from_markup(
366
+ f"{t(summary_key, lang=lang, project=project)}\n\n"
367
+ f"{t('run.next_steps', lang=lang, project=project)}"
368
+ ),
369
+ title=f"[bold {border}]{t('run.summary_title', lang=lang)}[/bold {border}]",
370
+ border_style=border,
371
+ padding=(1, 2),
372
+ )
373
+ )
374
+ console.print()
375
+ sys.exit(result.returncode)
@@ -0,0 +1,185 @@
1
+ """arch signup — Free mode interactive registration with lead capture.
2
+
3
+ Flow:
4
+ 1. Welcome banner (4-language)
5
+ 2. Interactive prompts: full_name, email, phone, country, language
6
+ 3. GDPR consent (mandatory — Italian/EU compliance)
7
+ 4. POST → cli-signup EF (validates server-side, creates shadow user, lead)
8
+ 5. Save returned token to ~/.lovarch/credentials.json (chmod 0600)
9
+ 6. Success message + next-steps (arch init, arch run)
10
+
11
+ Localization: i18n keys live in lovarch_cli/i18n/translations/{it,pt,en,es}.json.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import re
17
+ import sys
18
+ from datetime import datetime, timezone
19
+ from typing import Annotated
20
+
21
+ import typer
22
+ from rich.console import Console
23
+ from rich.panel import Panel
24
+ from rich.prompt import Confirm, Prompt
25
+ from rich.text import Text
26
+
27
+ from lovarch_cli.api import ApiClient, LovarchApiError
28
+ from lovarch_cli.config import Credentials, save_credentials
29
+ from lovarch_cli.i18n import current_lang, set_current_lang, t
30
+ from lovarch_cli.i18n.loader import VALID_LANGUAGES
31
+
32
+ console = Console()
33
+ err_console = Console(stderr=True)
34
+
35
+ EMAIL_RX = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
36
+ PHONE_RX = re.compile(r"^\+?[1-9]\d{6,14}$")
37
+ COUNTRY_RX = re.compile(r"^[A-Z]{2}$")
38
+
39
+
40
+ def signup_command(
41
+ yes: Annotated[
42
+ bool,
43
+ typer.Option(
44
+ "--yes",
45
+ "-y",
46
+ help="Auto-accept TOS (skip GDPR consent prompt — for CI only).",
47
+ ),
48
+ ] = False,
49
+ lang_flag: Annotated[
50
+ str | None,
51
+ typer.Option(
52
+ "--lang",
53
+ help="Force language (it/pt/en/es). Default: detect from $LANG.",
54
+ ),
55
+ ] = None,
56
+ ) -> None:
57
+ """Cadastro Free interativo (interactive Free signup).
58
+
59
+ Use --yes to auto-accept TOS for non-interactive (CI/Docker) flows.
60
+ """
61
+ # ─── Language detection ──────────────────────────────────────────────
62
+ if lang_flag is not None:
63
+ set_current_lang(lang_flag)
64
+ lang = current_lang()
65
+
66
+ # ─── Welcome banner ──────────────────────────────────────────────────
67
+ console.print()
68
+ console.print(
69
+ Panel(
70
+ Text.from_markup(t("signup.welcome_body", lang=lang)),
71
+ title=f"[bold gold1]🎓 {t('signup.welcome_title', lang=lang)}[/bold gold1]",
72
+ border_style="gold1",
73
+ padding=(1, 2),
74
+ )
75
+ )
76
+ console.print()
77
+
78
+ # ─── Confirm/select language ─────────────────────────────────────────
79
+ lang = Prompt.ask(
80
+ f"[bold]{t('signup.prompt_language', lang=lang)}[/bold]",
81
+ choices=list(VALID_LANGUAGES),
82
+ default=lang,
83
+ )
84
+ set_current_lang(lang)
85
+
86
+ # ─── Collect inputs ──────────────────────────────────────────────────
87
+ while True:
88
+ full_name = Prompt.ask(
89
+ f"[bold]{t('signup.prompt_full_name', lang=lang)}[/bold]"
90
+ ).strip()
91
+ if len(full_name) >= 3:
92
+ break
93
+ err_console.print(f"[red]{t('signup.name_too_short', lang=lang)}[/red]")
94
+
95
+ while True:
96
+ email = (
97
+ Prompt.ask(f"[bold]{t('signup.prompt_email', lang=lang)}[/bold]")
98
+ .strip()
99
+ .lower()
100
+ )
101
+ if EMAIL_RX.match(email):
102
+ break
103
+ err_console.print(f"[red]{t('signup.invalid_email', lang=lang)}[/red]")
104
+
105
+ while True:
106
+ phone = Prompt.ask(
107
+ f"[bold]{t('signup.prompt_phone', lang=lang)}[/bold]"
108
+ ).strip()
109
+ if PHONE_RX.match(phone):
110
+ break
111
+ err_console.print(f"[red]{t('signup.invalid_phone', lang=lang)}[/red]")
112
+
113
+ while True:
114
+ country = (
115
+ Prompt.ask(f"[bold]{t('signup.prompt_country', lang=lang)}[/bold]")
116
+ .strip()
117
+ .upper()
118
+ )
119
+ if COUNTRY_RX.match(country):
120
+ break
121
+ err_console.print(f"[red]{t('signup.invalid_country', lang=lang)}[/red]")
122
+
123
+ # ─── GDPR consent (mandatory) ────────────────────────────────────────
124
+ if not yes:
125
+ console.print(
126
+ f"\n[dim]{t('signup.tos_url_label', lang=lang)} "
127
+ f"https://lovarch.com/legal/cli-tos[/dim]"
128
+ )
129
+ accept = Confirm.ask(
130
+ f"[bold yellow]{t('signup.prompt_consent', lang=lang)}[/bold yellow]",
131
+ default=False,
132
+ )
133
+ if not accept:
134
+ err_console.print(
135
+ f"\n[red]✗ {t('signup.consent_required', lang=lang)}[/red]"
136
+ )
137
+ raise typer.Exit(1)
138
+
139
+ # ─── Submit to EF ────────────────────────────────────────────────────
140
+ console.print(f"\n[dim]{t('signup.submitting', lang=lang)}[/dim]")
141
+ api = ApiClient()
142
+ payload = {
143
+ "full_name": full_name,
144
+ "email": email,
145
+ "phone": phone,
146
+ "country": country,
147
+ "language": lang,
148
+ "source": "cli-free",
149
+ "accept_tos": True,
150
+ "cli_version": "0.1.0",
151
+ }
152
+
153
+ try:
154
+ response = asyncio.run(api.invoke_ef("cli-signup", payload))
155
+ except LovarchApiError as exc:
156
+ err_console.print(f"\n[red]✗ {exc}[/red]")
157
+ if exc.error_code:
158
+ err_console.print(f"[dim](error_code: {exc.error_code})[/dim]")
159
+ sys.exit(1)
160
+
161
+ # ─── Save credentials ────────────────────────────────────────────────
162
+ creds = Credentials(
163
+ mode="free",
164
+ lead_id=response["lead_id"],
165
+ user_id=response["user_id"],
166
+ free_token=response["free_token"],
167
+ email=email,
168
+ full_name=full_name,
169
+ country=country,
170
+ language=lang,
171
+ signed_up_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
172
+ upgrade_url=response.get("upgrade_url"),
173
+ )
174
+ creds_path = save_credentials(creds)
175
+
176
+ # ─── Success ─────────────────────────────────────────────────────────
177
+ console.print()
178
+ console.print(
179
+ Panel(
180
+ Text.from_markup(t("signup.next_steps", lang=lang)),
181
+ border_style="green",
182
+ padding=(1, 2),
183
+ )
184
+ )
185
+ console.print(f"\n[dim]Credentials: {creds_path}[/dim]")