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,372 @@
1
+ """Tool implementations for the Lovarch MCP server.
2
+
3
+ Kept as plain, dependency-injected functions (independent of the FastMCP wiring)
4
+ so they can be unit-tested directly. ``server.py`` wraps each of these in an
5
+ ``@mcp.tool()``.
6
+
7
+ Every tool that runs paid AI goes through :class:`LovarchAiGateway`, so the
8
+ user's Lovarch credits are debited by the 1000cr=$1 rule — the MCP server never
9
+ calls a model provider directly.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ from lovarch_cli.ai import AiGatewayError, InsufficientCreditsError, LovarchAiGateway
19
+ from lovarch_cli.credits.lovarch import LovarchCreditsClient
20
+
21
+
22
+ def _projects_root(home: Path | None = None) -> Path:
23
+ base = home or (Path.home() / ".lovarch")
24
+ return base / "projects"
25
+
26
+
27
+ async def tool_whoami(session: Any) -> dict:
28
+ """Report the authenticated Lovarch user and CLI mode."""
29
+ if session is None:
30
+ return {
31
+ "authenticated": False,
32
+ "mode": "none",
33
+ "hint": "Esegui `lovarch login --premium` per autenticarti.",
34
+ }
35
+ return {
36
+ "authenticated": True,
37
+ "mode": "premium",
38
+ "user_id": session.user_id,
39
+ "email": session.email,
40
+ }
41
+
42
+
43
+ async def tool_credits(session: Any) -> dict:
44
+ """Return the user's Lovarch credit balance (does not debit)."""
45
+ if session is None:
46
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
47
+ balance = await LovarchCreditsClient(session).check()
48
+ return {
49
+ "balance": balance.balance,
50
+ "monthly_used": balance.monthly_used,
51
+ "credits_remaining": balance.credits_remaining,
52
+ "is_admin": balance.is_admin,
53
+ }
54
+
55
+
56
+ async def tool_generate_image(
57
+ gateway: LovarchAiGateway | None,
58
+ *,
59
+ prompt: str,
60
+ output_path: str,
61
+ quality: str = "medium",
62
+ aspect: str = "1:1",
63
+ mode: str = "generate",
64
+ image_urls: list[str] | None = None,
65
+ ) -> dict:
66
+ """Generate an image via the platform (debits credits) and save it to disk.
67
+
68
+ Returns the saved path and the exact number of credits charged.
69
+ """
70
+ if gateway is None:
71
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
72
+ try:
73
+ result = await gateway.generate_image(
74
+ prompt,
75
+ quality=quality, # type: ignore[arg-type]
76
+ aspect=aspect,
77
+ mode=mode, # type: ignore[arg-type]
78
+ image_urls=image_urls,
79
+ operation_type="mcp:generate_image",
80
+ )
81
+ except InsufficientCreditsError as exc:
82
+ return {
83
+ "ok": False,
84
+ "error": "insufficient_credits",
85
+ "credits_available": exc.available,
86
+ "credits_needed": exc.needed,
87
+ }
88
+ except AiGatewayError as exc:
89
+ return {"ok": False, "error": str(exc)}
90
+
91
+ out = Path(output_path).expanduser()
92
+ out.parent.mkdir(parents=True, exist_ok=True)
93
+ out.write_bytes(result.image_bytes)
94
+ return {
95
+ "ok": True,
96
+ "saved_to": str(out),
97
+ "content_type": result.content_type,
98
+ "credits_charged": result.credits_charged,
99
+ "balance": result.balance,
100
+ "revised_prompt": result.revised_prompt,
101
+ }
102
+
103
+
104
+ def tool_audit_input(project_dir: str) -> dict:
105
+ """Run the 18-point input audit on a project's ``input/`` directory."""
106
+ # Imported lazily to avoid a hard dependency at module import time.
107
+ from lovarch_cli.commands.audit import _overall_verdict, _run_checks
108
+
109
+ root = Path(project_dir).expanduser()
110
+ input_dir = root / "input" if (root / "input").exists() else root
111
+ if not input_dir.exists():
112
+ return {"error": "input_dir_not_found", "path": str(input_dir)}
113
+ results = _run_checks(input_dir)
114
+ verdict = _overall_verdict(results)
115
+ return {
116
+ "verdict": verdict.value,
117
+ "checks": [
118
+ {"index": r.index, "key": r.key, "status": r.status.value,
119
+ "detail": r.detail, "required": r.required}
120
+ for r in results
121
+ ],
122
+ }
123
+
124
+
125
+ def tool_list_projects(home: Path | None = None) -> dict:
126
+ """List local Lovarch projects with their workflow + last audit verdict."""
127
+ root = _projects_root(home)
128
+ if not root.exists():
129
+ return {"projects": []}
130
+ projects = []
131
+ for child in sorted(root.iterdir()):
132
+ meta_file = child / "project.yaml"
133
+ if not meta_file.is_file():
134
+ continue
135
+ try:
136
+ meta = yaml.safe_load(meta_file.read_text()) or {}
137
+ except yaml.YAMLError:
138
+ meta = {}
139
+ projects.append({
140
+ "name": child.name,
141
+ "workflow": meta.get("workflow"),
142
+ "last_audit": (meta.get("last_audit") or {}).get("verdict"),
143
+ "last_run": (meta.get("last_run") or {}).get("status"),
144
+ })
145
+ return {"projects": projects}
146
+
147
+
148
+ async def tool_ai_text(
149
+ gateway: Any,
150
+ *,
151
+ prompt: str,
152
+ role: str = "executor",
153
+ model: str | None = None,
154
+ system: str | None = None,
155
+ max_tokens: int | None = None,
156
+ language: str | None = None,
157
+ ) -> dict:
158
+ """Generate text via the platform gateway (debits credits by real tokens)."""
159
+ if gateway is None:
160
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
161
+ try:
162
+ result = await gateway.generate_text(
163
+ prompt, role=role, model=model, system=system,
164
+ max_tokens=max_tokens, language=language,
165
+ operation_type="mcp:ai_text",
166
+ )
167
+ except InsufficientCreditsError as exc:
168
+ return {"ok": False, "error": "insufficient_credits",
169
+ "credits_available": exc.available, "credits_needed": exc.needed}
170
+ except AiGatewayError as exc:
171
+ return {"ok": False, "error": str(exc)}
172
+ return {
173
+ "ok": True,
174
+ "text": result.text,
175
+ "model": result.model,
176
+ "credits_charged": result.credits_charged,
177
+ "balance": result.balance,
178
+ }
179
+
180
+
181
+ async def tool_user_context(gateway: Any, *, lead_id: str | None = None) -> dict:
182
+ """Fetch the user's personalization bundle (brand, style, signature, language)."""
183
+ if gateway is None:
184
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
185
+ try:
186
+ return await gateway.get_user_context(lead_id=lead_id)
187
+ except AiGatewayError as exc:
188
+ return {"ok": False, "error": str(exc)}
189
+
190
+
191
+ async def tool_render(
192
+ workflows: Any,
193
+ *,
194
+ description: str,
195
+ output_path: str,
196
+ mode: str | None = None,
197
+ render_style: str = "moderno",
198
+ aspect_ratio: str = "16:9",
199
+ reference_image_path: str | None = None,
200
+ language: str = "it",
201
+ ) -> dict:
202
+ """Photorealistic render via the platform (credits debited server-side)."""
203
+ if workflows is None:
204
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
205
+ from lovarch_cli.workflows import WorkflowError
206
+
207
+ try:
208
+ result = await workflows.render(
209
+ description, mode=mode, render_style=render_style,
210
+ aspect_ratio=aspect_ratio, reference_image_path=reference_image_path,
211
+ language=language,
212
+ )
213
+ except WorkflowError as exc:
214
+ return {"ok": False, "error": str(exc)}
215
+ out: dict = {"ok": True, "message": result.message}
216
+ image_bytes = result.image_bytes
217
+ if result.image_url:
218
+ # The EF persisted the render in the user's Lovarch storage — keep the
219
+ # URL AND download a local copy for CLI convenience (best-effort).
220
+ out["image_url"] = result.image_url
221
+ if image_bytes is None:
222
+ try:
223
+ import httpx
224
+
225
+ async with httpx.AsyncClient(timeout=60.0) as client:
226
+ resp = await client.get(result.image_url)
227
+ if resp.status_code == 200:
228
+ image_bytes = resp.content
229
+ except Exception: # noqa: BLE001
230
+ image_bytes = None
231
+ if image_bytes:
232
+ p = Path(output_path).expanduser()
233
+ p.parent.mkdir(parents=True, exist_ok=True)
234
+ p.write_bytes(image_bytes)
235
+ out["saved_to"] = str(p)
236
+ return out
237
+
238
+
239
+ async def tool_colors(
240
+ workflows: Any,
241
+ *,
242
+ style: str = "modern",
243
+ base_colors: list[str] | None = None,
244
+ image_url: str | None = None,
245
+ language: str = "it",
246
+ ) -> dict:
247
+ """Brand color palette via the platform."""
248
+ if workflows is None:
249
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
250
+ from lovarch_cli.workflows import WorkflowError
251
+
252
+ try:
253
+ return await workflows.colors(
254
+ style=style, base_colors=base_colors, image_url=image_url, language=language,
255
+ )
256
+ except WorkflowError as exc:
257
+ return {"ok": False, "error": str(exc)}
258
+
259
+
260
+ async def tool_copy(
261
+ workflows: Any,
262
+ *,
263
+ brief: str,
264
+ mode: str = "post",
265
+ slide_count: int = 5,
266
+ language: str = "it",
267
+ ) -> dict:
268
+ """Marketing copy (caption + hashtags) via the platform."""
269
+ if workflows is None:
270
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
271
+ from lovarch_cli.workflows import WorkflowError
272
+
273
+ try:
274
+ return await workflows.copy(brief, mode=mode, slide_count=slide_count, language=language)
275
+ except WorkflowError as exc:
276
+ return {"ok": False, "error": str(exc)}
277
+
278
+
279
+ def tool_verify_misure(dxf_path: str) -> dict:
280
+ """Deterministic DXF check (ISO layers, room labels, CNAPPC cartiglio). Free."""
281
+ from lovarch_cli.verify import verify_misure
282
+
283
+ report = verify_misure(dxf_path)
284
+ return {"verdict": report.verdict, "findings": report.findings, "stats": report.stats}
285
+
286
+
287
+ async def tool_verify_normativa(
288
+ gateway: Any, *, document_path: str, language: str = "it"
289
+ ) -> dict:
290
+ """Adversarial normative-citation check (Sonnet extracts → Opus refutes)."""
291
+ if gateway is None:
292
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
293
+ from lovarch_cli.verify import verify_normativa
294
+ from lovarch_cli.verify.normativa import NormativaError
295
+
296
+ try:
297
+ report = await verify_normativa(gateway, document_path, language=language)
298
+ except NormativaError as exc:
299
+ return {"ok": False, "error": str(exc)}
300
+ except InsufficientCreditsError as exc:
301
+ return {"ok": False, "error": "insufficient_credits",
302
+ "credits_available": exc.available, "credits_needed": exc.needed}
303
+ except AiGatewayError as exc:
304
+ return {"ok": False, "error": str(exc)}
305
+ return {
306
+ "ok": True,
307
+ "verdict": report.verdict,
308
+ "citations": report.citations,
309
+ "verdicts": report.verdicts,
310
+ "canonical_found": report.canonical_found,
311
+ "credits_charged": report.credits_charged,
312
+ "notes": report.notes,
313
+ }
314
+
315
+
316
+ async def tool_job_status(workflows: Any, *, job_id: str | None = None, limit: int = 10) -> dict:
317
+ """Async job status (video/export/upscale). Without job_id lists recent jobs."""
318
+ if workflows is None:
319
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
320
+ from lovarch_cli.workflows import WorkflowError
321
+
322
+ try:
323
+ if job_id:
324
+ return {"ok": True, "job": await workflows.job_status(job_id)}
325
+ return {"ok": True, "jobs": await workflows.jobs_list(limit=limit)}
326
+ except WorkflowError as exc:
327
+ return {"ok": False, "error": str(exc)}
328
+
329
+
330
+ async def tool_verify_contratto(
331
+ gateway: Any, *, document_path: str, language: str = "it"
332
+ ) -> dict:
333
+ """Adversarial CNAPPC contract check (structure + compenso QN_007)."""
334
+ if gateway is None:
335
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
336
+ from lovarch_cli.verify import verify_contratto
337
+ from lovarch_cli.verify.normativa import NormativaError
338
+
339
+ try:
340
+ report = await verify_contratto(gateway, document_path, language=language)
341
+ except NormativaError as exc:
342
+ return {"ok": False, "error": str(exc)}
343
+ except InsufficientCreditsError as exc:
344
+ return {"ok": False, "error": "insufficient_credits",
345
+ "credits_available": exc.available, "credits_needed": exc.needed}
346
+ except AiGatewayError as exc:
347
+ return {"ok": False, "error": str(exc)}
348
+ return {"ok": True, "verdict": report.verdict, "structure": report.structure,
349
+ "findings": report.findings, "credits_charged": report.credits_charged}
350
+
351
+
352
+ async def tool_verify_dossier(
353
+ gateway: Any, *, directory: str, language: str = "it", max_llm_files: int = 8
354
+ ) -> dict:
355
+ """Full standalone QA over a deliverables folder."""
356
+ if gateway is None:
357
+ return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
358
+ from lovarch_cli.verify import verify_dossier
359
+ from lovarch_cli.verify.normativa import NormativaError
360
+
361
+ try:
362
+ report = await verify_dossier(gateway, directory, language=language,
363
+ max_llm_files=max_llm_files)
364
+ except NormativaError as exc:
365
+ return {"ok": False, "error": str(exc)}
366
+ except InsufficientCreditsError as exc:
367
+ return {"ok": False, "error": "insufficient_credits",
368
+ "credits_available": exc.available, "credits_needed": exc.needed}
369
+ except AiGatewayError as exc:
370
+ return {"ok": False, "error": str(exc)}
371
+ return {"ok": True, "verdict": report.verdict, "files": report.files,
372
+ "skipped": report.skipped, "credits_charged": report.credits_charged}
@@ -0,0 +1,255 @@
1
+ """Lazy downloader for the villa-chianti sample-input asset.
2
+
3
+ The 49MB sample-input (photos/DXF/PDF) is NOT bundled in the wheel — it ships
4
+ via GitHub Releases asset `sample-villa-chianti.zip` and is fetched on demand
5
+ by `lovarch init --sample`.
6
+
7
+ Flow when `arch init --sample` is invoked:
8
+
9
+ 1. Check if bundled sample exists at lovarch_cli/squad/data/sample-input-
10
+ villa-chianti/. If yes → return its path (offline/dev case).
11
+ 2. Check cache at ~/.lovarch/cache/sample-villa-chianti/. If extracted
12
+ and SHA-verified → return cached path.
13
+ 3. Download sample-villa-chianti.zip from GitHub Releases with progress
14
+ bar, verify SHA256, extract to cache, return path.
15
+
16
+ The SHA256 is pinned per release (see SAMPLE_ASSET_SHA256). Bumping requires
17
+ re-pinning here. Pre-release builds may set this to None to skip verification
18
+ (NOT for prod).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import shutil
24
+ import zipfile
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ import httpx
30
+ from rich.console import Console
31
+ from rich.progress import (
32
+ BarColumn,
33
+ DownloadColumn,
34
+ Progress,
35
+ TextColumn,
36
+ TimeRemainingColumn,
37
+ TransferSpeedColumn,
38
+ )
39
+
40
+ from lovarch_cli.config import DEFAULT_HOME
41
+ from lovarch_cli.i18n import t
42
+
43
+
44
+ # Release pinning — bump together when shipping a new sample.
45
+ SAMPLE_RELEASE_TAG = "v0.1.0-beta.1"
46
+ SAMPLE_ASSET_NAME = "sample-villa-chianti.zip"
47
+ SAMPLE_ASSET_URL = (
48
+ f"https://github.com/ArchPrime-official/lovarch-cli/releases/download/"
49
+ f"{SAMPLE_RELEASE_TAG}/{SAMPLE_ASSET_NAME}"
50
+ )
51
+ SAMPLE_ASSET_SHA256 = (
52
+ "fa3c057adb9ea70c8338fa1102f1c807524ae847768ad01238cdf364fd335be4"
53
+ )
54
+ # When extracted, the zip contains a top-level dir named sample-input-villa-
55
+ # chianti/ — we keep this convention so the resulting cache path matches the
56
+ # bundled-squad path used elsewhere.
57
+ EXTRACTED_DIR_NAME = "sample-input-villa-chianti"
58
+
59
+ # Network limits.
60
+ DOWNLOAD_TIMEOUT_SECONDS = 120
61
+ DOWNLOAD_CHUNK_SIZE = 64 * 1024
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class SampleSource:
66
+ """Result of resolving the sample-input source path."""
67
+
68
+ path: Path
69
+ origin: str # "bundled" | "cache" | "download"
70
+
71
+
72
+ class SampleDownloadError(RuntimeError):
73
+ """Raised when the sample asset cannot be obtained or verified."""
74
+
75
+
76
+ def _bundled_dir(squad_src: Optional[Path] = None) -> Path:
77
+ """Path the build hook would populate (if sample bundled).
78
+
79
+ Honors the same override chain as the runner via squad_loader: an
80
+ explicit `squad_src` argument > `$LOVARCH_SQUAD_SRC` env var > bundled
81
+ vendor. Falls back silently to the bundled path if resolution raises
82
+ (so the lazy-download flow still kicks in instead of crashing).
83
+ """
84
+ from lovarch_cli.squad_loader import SquadNotFoundError, resolve_squad_root
85
+
86
+ try:
87
+ root = resolve_squad_root(override=squad_src)
88
+ except SquadNotFoundError:
89
+ # Resolution failed (no override, no bundled) — fall through to the
90
+ # bundled path so downstream cache/download logic handles "not
91
+ # bundled" naturally.
92
+ root = Path(__file__).resolve().parent / "squad"
93
+ return root / "data" / EXTRACTED_DIR_NAME
94
+
95
+
96
+ def _cache_root(home: Optional[Path] = None) -> Path:
97
+ base = home or DEFAULT_HOME
98
+ return base / "cache"
99
+
100
+
101
+ def _cached_dir(home: Optional[Path] = None) -> Path:
102
+ return _cache_root(home) / EXTRACTED_DIR_NAME
103
+
104
+
105
+ def _cached_zip(home: Optional[Path] = None) -> Path:
106
+ return _cache_root(home) / SAMPLE_ASSET_NAME
107
+
108
+
109
+ def _sha256_of(path: Path) -> str:
110
+ hasher = hashlib.sha256()
111
+ with path.open("rb") as fh:
112
+ for chunk in iter(lambda: fh.read(DOWNLOAD_CHUNK_SIZE), b""):
113
+ hasher.update(chunk)
114
+ return hasher.hexdigest()
115
+
116
+
117
+ def _download_with_progress(
118
+ url: str,
119
+ dst: Path,
120
+ console: Console,
121
+ lang: Optional[str],
122
+ ) -> None:
123
+ """Stream-download `url` to `dst` with a rich progress bar."""
124
+ dst.parent.mkdir(parents=True, exist_ok=True)
125
+ tmp = dst.with_suffix(dst.suffix + ".partial")
126
+ try:
127
+ with httpx.stream(
128
+ "GET",
129
+ url,
130
+ timeout=DOWNLOAD_TIMEOUT_SECONDS,
131
+ follow_redirects=True,
132
+ ) as response:
133
+ response.raise_for_status()
134
+ total = int(response.headers.get("content-length", 0)) or None
135
+ with Progress(
136
+ TextColumn("[bold gold1]⬇ {task.description}"),
137
+ BarColumn(bar_width=None),
138
+ DownloadColumn(),
139
+ TransferSpeedColumn(),
140
+ TimeRemainingColumn(),
141
+ console=console,
142
+ transient=True,
143
+ ) as progress:
144
+ task_id = progress.add_task(
145
+ t("init.downloading", lang=lang, asset=SAMPLE_ASSET_NAME),
146
+ total=total,
147
+ )
148
+ with tmp.open("wb") as fh:
149
+ for chunk in response.iter_bytes(DOWNLOAD_CHUNK_SIZE):
150
+ fh.write(chunk)
151
+ progress.update(task_id, advance=len(chunk))
152
+ tmp.replace(dst)
153
+ except httpx.HTTPError as exc:
154
+ tmp.unlink(missing_ok=True)
155
+ raise SampleDownloadError(
156
+ t("init.download_failed", lang=lang, url=url, error=str(exc))
157
+ ) from exc
158
+
159
+
160
+ def _verify_zip(zip_path: Path, lang: Optional[str]) -> None:
161
+ if not SAMPLE_ASSET_SHA256:
162
+ return # Pre-release / explicit skip
163
+ actual = _sha256_of(zip_path)
164
+ if actual != SAMPLE_ASSET_SHA256:
165
+ raise SampleDownloadError(
166
+ t(
167
+ "init.checksum_failed",
168
+ lang=lang,
169
+ expected=SAMPLE_ASSET_SHA256,
170
+ actual=actual,
171
+ )
172
+ )
173
+
174
+
175
+ def _extract_into_cache(
176
+ zip_path: Path,
177
+ cache_root: Path,
178
+ lang: Optional[str],
179
+ ) -> Path:
180
+ """Extract zip into cache_root/EXTRACTED_DIR_NAME, replacing any prior copy."""
181
+ target = cache_root / EXTRACTED_DIR_NAME
182
+ if target.exists():
183
+ shutil.rmtree(target)
184
+ cache_root.mkdir(parents=True, exist_ok=True)
185
+ try:
186
+ with zipfile.ZipFile(zip_path) as zf:
187
+ # Defense against path-traversal in zip entries.
188
+ for name in zf.namelist():
189
+ resolved = (cache_root / name).resolve()
190
+ if cache_root.resolve() not in resolved.parents and resolved != (
191
+ cache_root / EXTRACTED_DIR_NAME
192
+ ).resolve():
193
+ if not str(resolved).startswith(str(cache_root.resolve())):
194
+ raise SampleDownloadError(
195
+ t("init.zip_unsafe", lang=lang, entry=name)
196
+ )
197
+ zf.extractall(cache_root)
198
+ except (zipfile.BadZipFile, OSError) as exc:
199
+ raise SampleDownloadError(
200
+ t("init.extract_failed", lang=lang, error=str(exc))
201
+ ) from exc
202
+ if not target.exists() or not target.is_dir():
203
+ raise SampleDownloadError(
204
+ t("init.unexpected_zip_layout", lang=lang, expected=EXTRACTED_DIR_NAME)
205
+ )
206
+ return target
207
+
208
+
209
+ def resolve_sample_source(
210
+ *,
211
+ console: Console,
212
+ lang: Optional[str] = None,
213
+ home: Optional[Path] = None,
214
+ allow_download: bool = True,
215
+ squad_src: Optional[Path] = None,
216
+ ) -> SampleSource:
217
+ """Return a populated SampleSource, downloading & caching if needed.
218
+
219
+ Resolution order:
220
+ 1. Bundled inside the resolved squad root (honors --squad-src /
221
+ $LOVARCH_SQUAD_SRC overrides — see lovarch_cli.squad_loader)
222
+ 2. Cached extraction in ~/.lovarch/cache/...
223
+ 3. Fresh download from GitHub Releases (if allow_download)
224
+
225
+ Raises SampleDownloadError on network/integrity/IO failures when neither
226
+ bundled nor cache is usable.
227
+ """
228
+ bundled = _bundled_dir(squad_src=squad_src)
229
+ if bundled.is_dir() and any(bundled.iterdir()):
230
+ return SampleSource(path=bundled, origin="bundled")
231
+
232
+ cached = _cached_dir(home)
233
+ if cached.is_dir() and any(cached.iterdir()):
234
+ return SampleSource(path=cached, origin="cache")
235
+
236
+ if not allow_download:
237
+ raise SampleDownloadError(t("init.no_sample_offline", lang=lang))
238
+
239
+ zip_path = _cached_zip(home)
240
+ if zip_path.exists():
241
+ # Reuse a previously-downloaded zip if checksum still matches.
242
+ try:
243
+ _verify_zip(zip_path, lang)
244
+ except SampleDownloadError:
245
+ zip_path.unlink(missing_ok=True)
246
+
247
+ if not zip_path.exists():
248
+ console.print(
249
+ f"[dim]{t('init.fetching_sample', lang=lang, url=SAMPLE_ASSET_URL)}[/dim]"
250
+ )
251
+ _download_with_progress(SAMPLE_ASSET_URL, zip_path, console, lang)
252
+ _verify_zip(zip_path, lang)
253
+
254
+ extracted = _extract_into_cache(zip_path, _cache_root(home), lang)
255
+ return SampleSource(path=extracted, origin="download")