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,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]")
|