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,172 @@
1
+ """arch account — User account management subcommands.
2
+
3
+ Currently exposes:
4
+ arch account delete GDPR right-to-erasure (free + premium)
5
+ arch account info Show currently logged-in account
6
+
7
+ For free users: hits cli-account-delete EF, pseudonymizes lead, deletes shadow
8
+ auth.user, clears local credentials + optionally local artifacts.
9
+
10
+ For premium users: TODO Epic 3 — chains to cli-premium-deactivate.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import shutil
16
+ import sys
17
+ from typing import Annotated
18
+
19
+ import typer
20
+ from rich.console import Console
21
+ from rich.panel import Panel
22
+ from rich.prompt import Confirm
23
+ from rich.text import Text
24
+
25
+ from lovarch_cli.api import ApiClient, LovarchApiError
26
+ from lovarch_cli.config import (
27
+ DEFAULT_HOME,
28
+ Credentials,
29
+ clear_credentials,
30
+ load_credentials,
31
+ )
32
+ from lovarch_cli.i18n import t
33
+
34
+ console = Console()
35
+ err_console = Console(stderr=True)
36
+
37
+ app = typer.Typer(
38
+ name="account",
39
+ help="Gestione account (info, delete GDPR).",
40
+ no_args_is_help=True,
41
+ )
42
+
43
+
44
+ @app.command(name="info", help="Mostra info dell'account corrente.")
45
+ def account_info() -> None:
46
+ """Show currently logged-in account info."""
47
+ creds = load_credentials()
48
+ if creds.mode == "none":
49
+ err_console.print(
50
+ f"[yellow]{t('account.no_account', lang=creds.language)}[/yellow]"
51
+ )
52
+ sys.exit(1)
53
+
54
+ body = Text.assemble(
55
+ ("Mode: ", "dim"),
56
+ (f"{creds.mode}\n", "bold gold1"),
57
+ ("Email: ", "dim"),
58
+ (f"{creds.email or '—'}\n", "bold"),
59
+ ("Full name: ", "dim"),
60
+ (f"{creds.full_name or '—'}\n", "bold"),
61
+ ("Country: ", "dim"),
62
+ (f"{creds.country or '—'}\n", "bold"),
63
+ ("Language: ", "dim"),
64
+ (f"{creds.language}\n", "bold"),
65
+ ("Signed up: ", "dim"),
66
+ (f"{creds.signed_up_at or '—'}\n", "bold"),
67
+ ("Lead ID: ", "dim"),
68
+ (f"{creds.lead_id or '—'}\n", "bold dim"),
69
+ )
70
+ console.print(
71
+ Panel(
72
+ body,
73
+ title=f"[bold gold1]{t('account.info_title', lang=creds.language)}[/bold gold1]",
74
+ border_style="gold1",
75
+ padding=(1, 2),
76
+ )
77
+ )
78
+
79
+
80
+ @app.command(name="delete", help="Cancella account (GDPR right-to-erasure).")
81
+ def account_delete(
82
+ yes: Annotated[
83
+ bool,
84
+ typer.Option(
85
+ "--yes", "-y", help="Skip confirmation prompts (CI/scripts only)."
86
+ ),
87
+ ] = False,
88
+ keep_local: Annotated[
89
+ bool,
90
+ typer.Option(
91
+ "--keep-local",
92
+ help="Keep ~/.lovarch/projects/ local files after remote deletion.",
93
+ ),
94
+ ] = False,
95
+ ) -> None:
96
+ """Permanently delete CLI account (GDPR Art. 17 right-to-erasure)."""
97
+ creds: Credentials = load_credentials()
98
+ if creds.mode == "none" or not creds.free_token:
99
+ err_console.print(
100
+ f"[yellow]{t('account.no_account', lang=creds.language)}[/yellow]"
101
+ )
102
+ sys.exit(1)
103
+
104
+ lang = creds.language
105
+
106
+ if creds.mode == "premium":
107
+ err_console.print(
108
+ f"[yellow]{t('account.premium_delete_redirect', lang=lang)}[/yellow]"
109
+ )
110
+ sys.exit(1)
111
+
112
+ # ─── Warning panel ───────────────────────────────────────────────────
113
+ console.print()
114
+ console.print(
115
+ Panel(
116
+ Text.from_markup(t("account.delete_warning_body", lang=lang)),
117
+ title=f"[bold red]{t('account.delete_warning_title', lang=lang)}[/bold red]",
118
+ border_style="red",
119
+ padding=(1, 2),
120
+ )
121
+ )
122
+
123
+ # ─── Confirmations ───────────────────────────────────────────────────
124
+ if not yes:
125
+ if not Confirm.ask(
126
+ f"\n[bold red]{t('account.confirm_delete_remote', lang=lang)}[/bold red]",
127
+ default=False,
128
+ ):
129
+ console.print(
130
+ f"\n[yellow]{t('account.deletion_aborted', lang=lang)}[/yellow]"
131
+ )
132
+ sys.exit(0)
133
+
134
+ delete_local = not keep_local
135
+ if not yes and not keep_local:
136
+ delete_local = Confirm.ask(
137
+ f"\n[yellow]{t('account.confirm_delete_local', lang=lang)}[/yellow]",
138
+ default=True,
139
+ )
140
+
141
+ # ─── Remote deletion ─────────────────────────────────────────────────
142
+ api = ApiClient()
143
+ try:
144
+ asyncio.run(
145
+ api.invoke_ef(
146
+ "cli-account-delete",
147
+ {
148
+ "free_token": creds.free_token,
149
+ "language": lang,
150
+ "confirm": True,
151
+ },
152
+ )
153
+ )
154
+ except LovarchApiError as exc:
155
+ err_console.print(f"\n[red]✗ {exc}[/red]")
156
+ if exc.error_code:
157
+ err_console.print(f"[dim](error_code: {exc.error_code})[/dim]")
158
+ sys.exit(1)
159
+
160
+ # ─── Local cleanup ───────────────────────────────────────────────────
161
+ clear_credentials()
162
+ if delete_local:
163
+ projects_dir = DEFAULT_HOME / "projects"
164
+ if projects_dir.exists():
165
+ shutil.rmtree(projects_dir, ignore_errors=True)
166
+ db_path = DEFAULT_HOME / "local.db"
167
+ if db_path.exists():
168
+ db_path.unlink()
169
+
170
+ console.print(
171
+ f"\n[bold green]{t('account.deletion_success', lang=lang)}[/bold green]\n"
172
+ )
@@ -0,0 +1,394 @@
1
+ """lovarch audit — 18-point input validation checklist.
2
+
3
+ Inspects ~/.lovarch/projects/<name>/input/ and runs an 18-check audit that
4
+ mirrors the @auditor-input Tier-0 gate inside the squad pipeline. Output:
5
+
6
+ - Rich table with one row per check (#, name, status, detail)
7
+ - Summary verdict (PASS / CONCERNS / FAIL)
8
+ - Exit code 0 for PASS/CONCERNS, 1 for FAIL
9
+ - --json flag for machine-readable output (CI pipelines)
10
+
11
+ The checklist is divided into three tiers:
12
+ REQUIRED (10): missing → FAIL the whole audit
13
+ RECOMMENDED (4): missing → CONCERNS verdict, not blocking
14
+ BRIEFING (4): keyword checks inside briefing-cliente.md
15
+
16
+ This is intentionally permissive on RECOMMENDED so a user can still drive a
17
+ run with a partial input set (e.g. only 4 photos instead of 6), but the
18
+ required core (briefing, DXF, photos≥4, pinterest≥4, visura, architetto JSON)
19
+ is hard-gated.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import sys
25
+ from dataclasses import dataclass
26
+ from enum import Enum
27
+ from pathlib import Path
28
+ from typing import Annotated
29
+
30
+ import typer
31
+ import yaml
32
+ from rich.console import Console
33
+ from rich.panel import Panel
34
+ from rich.table import Table
35
+ from rich.text import Text
36
+
37
+ from lovarch_cli.config import DEFAULT_HOME
38
+ from lovarch_cli.i18n import current_lang, set_current_lang, t
39
+
40
+ console = Console()
41
+ err_console = Console(stderr=True)
42
+
43
+ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".heic", ".webp"}
44
+
45
+
46
+ class CheckStatus(str, Enum):
47
+ PASS = "PASS"
48
+ CONCERNS = "CONCERNS"
49
+ FAIL = "FAIL"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class CheckResult:
54
+ """One row in the audit table."""
55
+
56
+ index: int
57
+ key: str # i18n key suffix (e.g. "briefing_present")
58
+ status: CheckStatus
59
+ detail: str # short human-readable explanation
60
+ required: bool # True = FAIL blocks audit; False = downgrades to CONCERNS
61
+
62
+
63
+ # ──────────────────────────────────────────────────────────────────────────
64
+ # Individual checkers
65
+ # ──────────────────────────────────────────────────────────────────────────
66
+
67
+
68
+ def _check_file(
69
+ input_dir: Path,
70
+ filename: str,
71
+ min_size_bytes: int = 0,
72
+ required: bool = True,
73
+ ) -> tuple[CheckStatus, str]:
74
+ path = input_dir / filename
75
+ if not path.exists():
76
+ return (CheckStatus.FAIL if required else CheckStatus.CONCERNS,
77
+ f"missing: {filename}")
78
+ size = path.stat().st_size
79
+ if size < min_size_bytes:
80
+ return (CheckStatus.FAIL if required else CheckStatus.CONCERNS,
81
+ f"{size}B < {min_size_bytes}B min")
82
+ return CheckStatus.PASS, f"{size:,}B"
83
+
84
+
85
+ def _check_image_dir(
86
+ input_dir: Path,
87
+ dirname: str,
88
+ min_count: int,
89
+ recommended_count: int,
90
+ ) -> tuple[CheckStatus, str]:
91
+ path = input_dir / dirname
92
+ if not path.is_dir():
93
+ return CheckStatus.FAIL, f"missing dir: {dirname}/"
94
+ images = [
95
+ p for p in path.iterdir() if p.is_file() and p.suffix.lower() in IMAGE_EXTS
96
+ ]
97
+ n = len(images)
98
+ if n < min_count:
99
+ return CheckStatus.FAIL, f"{n} images, need ≥{min_count}"
100
+ if n < recommended_count:
101
+ return CheckStatus.CONCERNS, f"{n} images (recommended ≥{recommended_count})"
102
+ return CheckStatus.PASS, f"{n} images"
103
+
104
+
105
+ def _check_json_keys(
106
+ input_dir: Path, filename: str, required_keys: list[str]
107
+ ) -> tuple[CheckStatus, str]:
108
+ path = input_dir / filename
109
+ if not path.exists():
110
+ return CheckStatus.FAIL, f"missing: {filename}"
111
+ try:
112
+ data = json.loads(path.read_text(encoding="utf-8"))
113
+ except (json.JSONDecodeError, OSError) as exc:
114
+ return CheckStatus.FAIL, f"invalid JSON: {exc}"
115
+ missing = [k for k in required_keys if k not in data]
116
+ if missing:
117
+ return CheckStatus.FAIL, f"missing keys: {', '.join(missing)}"
118
+ return CheckStatus.PASS, f"{len(required_keys)} required keys present"
119
+
120
+
121
+ def _check_briefing_keyword(
122
+ input_dir: Path, keyword: str, min_occurrences: int = 1
123
+ ) -> tuple[CheckStatus, str]:
124
+ path = input_dir / "briefing-cliente.md"
125
+ if not path.exists():
126
+ return CheckStatus.FAIL, "briefing not found"
127
+ text = path.read_text(encoding="utf-8").lower()
128
+ n = text.count(keyword.lower())
129
+ if n < min_occurrences:
130
+ return CheckStatus.CONCERNS, f"'{keyword}' not mentioned"
131
+ return CheckStatus.PASS, f"'{keyword}' x{n}"
132
+
133
+
134
+ def _check_briefing_size(input_dir: Path, min_lines: int = 50) -> tuple[CheckStatus, str]:
135
+ path = input_dir / "briefing-cliente.md"
136
+ if not path.exists():
137
+ return CheckStatus.FAIL, "missing: briefing-cliente.md"
138
+ lines = path.read_text(encoding="utf-8").count("\n")
139
+ if lines < min_lines:
140
+ return CheckStatus.CONCERNS, f"{lines} lines < {min_lines} recommended"
141
+ return CheckStatus.PASS, f"{lines} lines"
142
+
143
+
144
+ # ──────────────────────────────────────────────────────────────────────────
145
+ # Full 18-point checklist
146
+ # ──────────────────────────────────────────────────────────────────────────
147
+
148
+
149
+ def _run_checks(input_dir: Path) -> list[CheckResult]:
150
+ """Execute all 18 checks and return ordered results."""
151
+ results: list[CheckResult] = []
152
+
153
+ # Required (10) — FAIL here breaks the audit
154
+ s, d = _check_briefing_size(input_dir, min_lines=50)
155
+ results.append(CheckResult(1, "briefing_present", s, d, required=True))
156
+
157
+ s, d = _check_briefing_keyword(input_dir, "cliente")
158
+ results.append(CheckResult(2, "briefing_cliente", s, d, required=True))
159
+
160
+ s, d = _check_briefing_keyword(input_dir, "programma")
161
+ results.append(CheckResult(3, "briefing_programma", s, d, required=True))
162
+
163
+ s, d = _check_file(input_dir, "stato-attuale.dxf", min_size_bytes=5_000, required=True)
164
+ results.append(CheckResult(4, "dxf_present", s, d, required=True))
165
+
166
+ s, d = _check_file(input_dir, "stato-attuale.png", min_size_bytes=1_000, required=True)
167
+ results.append(CheckResult(5, "preview_png", s, d, required=True))
168
+
169
+ s, d = _check_file(input_dir, "visura-catastale.pdf", min_size_bytes=1_000, required=True)
170
+ results.append(CheckResult(6, "visura_present", s, d, required=True))
171
+
172
+ s, d = _check_json_keys(
173
+ input_dir,
174
+ "architetto-info.json",
175
+ ["nome", "iscrizione_ordine", "codice_fiscale"],
176
+ )
177
+ results.append(CheckResult(7, "architetto_json", s, d, required=True))
178
+
179
+ s, d = _check_image_dir(input_dir, "foto", min_count=4, recommended_count=6)
180
+ results.append(CheckResult(8, "foto_dir", s, d, required=True))
181
+
182
+ s, d = _check_image_dir(
183
+ input_dir, "pinterest-references", min_count=4, recommended_count=6
184
+ )
185
+ results.append(CheckResult(9, "pinterest_dir", s, d, required=True))
186
+
187
+ s, d = _check_briefing_keyword(input_dir, "budget")
188
+ results.append(CheckResult(10, "briefing_budget", s, d, required=True))
189
+
190
+ # Recommended (4) — CONCERNS only, not blocking
191
+ s, d = _check_briefing_keyword(input_dir, "tempi")
192
+ results.append(CheckResult(11, "briefing_tempi", s, d, required=False))
193
+
194
+ s, d = _check_briefing_keyword(input_dir, "vincoli")
195
+ results.append(CheckResult(12, "briefing_vincoli", s, d, required=False))
196
+
197
+ s, d = _check_briefing_keyword(input_dir, "stile")
198
+ results.append(CheckResult(13, "briefing_stile", s, d, required=False))
199
+
200
+ s, d = _check_briefing_keyword(input_dir, "spazi")
201
+ results.append(CheckResult(14, "briefing_spazi", s, d, required=False))
202
+
203
+ # Briefing depth checks (4) — CONCERNS only
204
+ s, d = _check_briefing_keyword(input_dir, "famiglia")
205
+ results.append(CheckResult(15, "briefing_famiglia", s, d, required=False))
206
+
207
+ s, d = _check_briefing_keyword(input_dir, "abitudini")
208
+ results.append(CheckResult(16, "briefing_abitudini", s, d, required=False))
209
+
210
+ s, d = _check_briefing_keyword(input_dir, "materiali")
211
+ results.append(CheckResult(17, "briefing_materiali", s, d, required=False))
212
+
213
+ s, d = _check_briefing_keyword(input_dir, "sostenibilità", min_occurrences=1)
214
+ if s != CheckStatus.PASS:
215
+ # fall back to plain ascii 'sostenibilita'
216
+ s, d = _check_briefing_keyword(input_dir, "sostenibilita")
217
+ results.append(CheckResult(18, "briefing_sostenibilita", s, d, required=False))
218
+
219
+ return results
220
+
221
+
222
+ def _overall_verdict(results: list[CheckResult]) -> CheckStatus:
223
+ """PASS only if everything green; FAIL if any required FAIL; else CONCERNS."""
224
+ any_required_fail = any(
225
+ r.required and r.status == CheckStatus.FAIL for r in results
226
+ )
227
+ if any_required_fail:
228
+ return CheckStatus.FAIL
229
+ any_concerns = any(r.status != CheckStatus.PASS for r in results)
230
+ return CheckStatus.CONCERNS if any_concerns else CheckStatus.PASS
231
+
232
+
233
+ # ──────────────────────────────────────────────────────────────────────────
234
+ # Render helpers
235
+ # ──────────────────────────────────────────────────────────────────────────
236
+
237
+
238
+ _STATUS_STYLE = {
239
+ CheckStatus.PASS: "[bold green]✓ PASS[/bold green]",
240
+ CheckStatus.CONCERNS: "[bold yellow]⚠ CONCERNS[/bold yellow]",
241
+ CheckStatus.FAIL: "[bold red]✗ FAIL[/bold red]",
242
+ }
243
+
244
+
245
+ def _render_table(results: list[CheckResult], lang: str) -> Table:
246
+ table = Table(
247
+ title=t("audit.checklist_title", lang=lang),
248
+ show_lines=False,
249
+ title_style="bold gold1",
250
+ header_style="bold",
251
+ )
252
+ table.add_column("#", justify="right", style="dim", width=3)
253
+ table.add_column(t("audit.col_check", lang=lang), no_wrap=True)
254
+ table.add_column(t("audit.col_status", lang=lang), no_wrap=True)
255
+ table.add_column(t("audit.col_detail", lang=lang), overflow="fold")
256
+
257
+ for r in results:
258
+ check_label = t(f"audit.checks.{r.key}", lang=lang)
259
+ table.add_row(
260
+ str(r.index),
261
+ check_label,
262
+ _STATUS_STYLE[r.status],
263
+ r.detail,
264
+ )
265
+ return table
266
+
267
+
268
+ # ──────────────────────────────────────────────────────────────────────────
269
+ # Typer command
270
+ # ──────────────────────────────────────────────────────────────────────────
271
+
272
+
273
+ def audit_command(
274
+ project: Annotated[
275
+ str, typer.Argument(help="Project name to audit.")
276
+ ],
277
+ json_output: Annotated[
278
+ bool,
279
+ typer.Option(
280
+ "--json",
281
+ help="Print machine-readable JSON instead of Rich table.",
282
+ ),
283
+ ] = False,
284
+ lang_flag: Annotated[
285
+ str | None,
286
+ typer.Option("--lang", "-l", help="Override language (it/pt/en/es)."),
287
+ ] = None,
288
+ home_override: Annotated[
289
+ Path | None,
290
+ typer.Option(
291
+ "--home",
292
+ help="Override $HOME/.lovarch root (mainly for tests).",
293
+ hidden=True,
294
+ ),
295
+ ] = None,
296
+ ) -> None:
297
+ """Validate project input against the 18-point checklist."""
298
+ if lang_flag is not None:
299
+ set_current_lang(lang_flag)
300
+ lang = current_lang()
301
+
302
+ home = home_override or DEFAULT_HOME
303
+ proj_dir = home / "projects" / project
304
+ if not proj_dir.is_dir():
305
+ err_console.print(
306
+ f"\n[red]✗ {t('audit.no_project', lang=lang, name=project)}[/red]\n"
307
+ )
308
+ sys.exit(2)
309
+
310
+ input_dir = proj_dir / "input"
311
+ if not input_dir.is_dir():
312
+ err_console.print(
313
+ f"\n[red]✗ {t('audit.no_input', lang=lang, path=str(input_dir))}[/red]\n"
314
+ )
315
+ sys.exit(2)
316
+
317
+ # ─── Run checks ──────────────────────────────────────────────────────
318
+ results = _run_checks(input_dir)
319
+ verdict = _overall_verdict(results)
320
+
321
+ # ─── Persist verdict in project.yaml ────────────────────────────────
322
+ yaml_path = proj_dir / "project.yaml"
323
+ if yaml_path.exists():
324
+ try:
325
+ meta = yaml.safe_load(yaml_path.read_text()) or {}
326
+ except yaml.YAMLError:
327
+ meta = {}
328
+ meta["last_audit"] = {
329
+ "verdict": verdict.value,
330
+ "pass_count": sum(1 for r in results if r.status == CheckStatus.PASS),
331
+ "concerns_count": sum(
332
+ 1 for r in results if r.status == CheckStatus.CONCERNS
333
+ ),
334
+ "fail_count": sum(1 for r in results if r.status == CheckStatus.FAIL),
335
+ }
336
+ yaml_path.write_text(
337
+ yaml.safe_dump(meta, sort_keys=False, allow_unicode=True),
338
+ encoding="utf-8",
339
+ )
340
+
341
+ # ─── JSON output (for CI) ─────────────────────────────────────────────
342
+ if json_output:
343
+ payload = {
344
+ "project": project,
345
+ "verdict": verdict.value,
346
+ "checks": [
347
+ {
348
+ "index": r.index,
349
+ "key": r.key,
350
+ "status": r.status.value,
351
+ "detail": r.detail,
352
+ "required": r.required,
353
+ }
354
+ for r in results
355
+ ],
356
+ }
357
+ console.print(json.dumps(payload, indent=2, ensure_ascii=False))
358
+ sys.exit(0 if verdict != CheckStatus.FAIL else 1)
359
+
360
+ # ─── Rich output ─────────────────────────────────────────────────────
361
+ console.print()
362
+ console.print(_render_table(results, lang))
363
+
364
+ summary_key = {
365
+ CheckStatus.PASS: "audit.summary_pass",
366
+ CheckStatus.CONCERNS: "audit.summary_concerns",
367
+ CheckStatus.FAIL: "audit.summary_fail",
368
+ }[verdict]
369
+ next_key = (
370
+ "audit.next_steps_fail" if verdict == CheckStatus.FAIL else "audit.next_steps_pass"
371
+ )
372
+
373
+ summary_text = Text.from_markup(
374
+ f"{_STATUS_STYLE[verdict]} — "
375
+ f"{t(summary_key, lang=lang, name=project)}\n\n"
376
+ f"{t(next_key, lang=lang, name=project)}"
377
+ )
378
+ border = {
379
+ CheckStatus.PASS: "green",
380
+ CheckStatus.CONCERNS: "yellow",
381
+ CheckStatus.FAIL: "red",
382
+ }[verdict]
383
+ console.print()
384
+ console.print(
385
+ Panel(
386
+ summary_text,
387
+ title=f"[bold {border}]{t('audit.summary_title', lang=lang)}[/bold {border}]",
388
+ border_style=border,
389
+ padding=(1, 2),
390
+ )
391
+ )
392
+ console.print()
393
+
394
+ sys.exit(0 if verdict != CheckStatus.FAIL else 1)
@@ -0,0 +1,80 @@
1
+ """`lovarch config` — manage user preferences and BYO API keys.
2
+
3
+ lovarch config show
4
+ lovarch config set language it
5
+ lovarch config set openai_key sk-...
6
+ lovarch config get storage_path
7
+ lovarch config unset mapbox_token
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from lovarch_cli import config_store
16
+
17
+ console = Console()
18
+ err_console = Console(stderr=True)
19
+
20
+ config_app = typer.Typer(
21
+ help="Configurazione utente (lingua, storage, API keys per il modo Free).",
22
+ no_args_is_help=True,
23
+ )
24
+
25
+
26
+ @config_app.command("show")
27
+ def show_command() -> None:
28
+ """Mostra la configurazione attuale (le chiavi segrete sono mascherate)."""
29
+ table = Table(title="lovarch config", show_header=True, header_style="bold gold1")
30
+ table.add_column("Chiave", style="cyan")
31
+ table.add_column("Valore")
32
+ table.add_column("Segreto", justify="center")
33
+ for key, masked, is_secret in config_store.display_items():
34
+ table.add_row(key, masked, "🔒" if is_secret else "")
35
+ console.print(table)
36
+ console.print(f"[dim]{config_store.config_path()}[/dim]")
37
+
38
+
39
+ @config_app.command("set")
40
+ def set_command(
41
+ key: str = typer.Argument(..., help="language | storage_path | openai_key | mapbox_token"),
42
+ value: str = typer.Argument(..., help="Il valore da impostare"),
43
+ ) -> None:
44
+ """Imposta una chiave di configurazione."""
45
+ try:
46
+ config_store.set_value(key, value)
47
+ except config_store.ConfigError as exc:
48
+ err_console.print(f"[red]✗ {exc}[/red]")
49
+ raise typer.Exit(1)
50
+ shown = config_store.mask(key, value)
51
+ console.print(f"[green]✓[/green] {key} = {shown}")
52
+
53
+
54
+ @config_app.command("get")
55
+ def get_command(
56
+ key: str = typer.Argument(..., help="La chiave da leggere"),
57
+ ) -> None:
58
+ """Legge il valore di una chiave (mascherato se segreto)."""
59
+ try:
60
+ value = config_store.get_value(key)
61
+ except config_store.ConfigError as exc:
62
+ err_console.print(f"[red]✗ {exc}[/red]")
63
+ raise typer.Exit(1)
64
+ console.print(config_store.mask(key, value))
65
+
66
+
67
+ @config_app.command("unset")
68
+ def unset_command(
69
+ key: str = typer.Argument(..., help="La chiave da rimuovere"),
70
+ ) -> None:
71
+ """Rimuove una chiave di configurazione."""
72
+ try:
73
+ removed = config_store.unset_value(key)
74
+ except config_store.ConfigError as exc:
75
+ err_console.print(f"[red]✗ {exc}[/red]")
76
+ raise typer.Exit(1)
77
+ if removed:
78
+ console.print(f"[green]✓[/green] rimosso: {key}")
79
+ else:
80
+ console.print(f"[dim]{key} non era impostato[/dim]")