arclith-cli 0.7.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.
@@ -0,0 +1,2 @@
1
+ __version__ = "0.7.1"
2
+
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ # ── Supported adapters ────────────────────────────────────────────────────────
6
+
7
+ SUPPORTED_ADAPTERS = ["memory", "mongodb", "duckdb"]
8
+
9
+ # ── Config YAML templates (scoped — no root key) ──────────────────────────────
10
+
11
+ CONFIG_YAML: dict[str, str] = {
12
+ "memory": "", # memory needs no config file
13
+ "mongodb": """\
14
+ multitenant: false # true = URI + db_name résolus par requête via JWT → Vault
15
+ db_name: {db_name} # uri → secrets.yaml ou Vault (fallback single-tenant)
16
+ """,
17
+ "duckdb": """\
18
+ multitenant: false
19
+ path: {path}
20
+ """,
21
+ }
22
+
23
+ # ── Python repository subclass templates ─────────────────────────────────────
24
+
25
+ REPO_PYTHON: dict[str, str] = {
26
+ "memory": """\
27
+ from arclith.adapters.output.memory.repository import InMemoryRepository
28
+ from domain.models.{snake} import {pascal}
29
+ from domain.ports.output.{snake}_repository import {pascal}Repository
30
+
31
+
32
+ class InMemory{pascal}Repository(InMemoryRepository[{pascal}], {pascal}Repository):
33
+ pass # TODO: add custom query methods if needed
34
+ """,
35
+ "mongodb": """\
36
+ from arclith.adapters.output.mongodb.config import MongoDBConfig
37
+ from arclith.adapters.output.mongodb.repository import MongoDBRepository
38
+ from arclith.domain.ports.logger import Logger
39
+ from domain.models.{snake} import {pascal}
40
+ from domain.ports.output.{snake}_repository import {pascal}Repository
41
+
42
+
43
+ class MongoDB{pascal}Repository(MongoDBRepository[{pascal}], {pascal}Repository):
44
+ def __init__(self, config: MongoDBConfig, logger: Logger) -> None:
45
+ super().__init__(config, {pascal}, logger)
46
+
47
+ # TODO: add custom query methods here
48
+ # async def find_by_name(self, name: str) -> list[{pascal}]:
49
+ # async with self._collection() as col:
50
+ # return [
51
+ # self._from_doc(doc)
52
+ # async for doc in col.find({{"name": name, "deleted_at": None}})
53
+ # ]
54
+ """,
55
+ "duckdb": """\
56
+ from arclith.adapters.output.duckdb.repository import DuckDBRepository
57
+ from domain.models.{snake} import {pascal}
58
+ from domain.ports.output.{snake}_repository import {pascal}Repository
59
+
60
+
61
+ class DuckDB{pascal}Repository(DuckDBRepository[{pascal}], {pascal}Repository):
62
+ def __init__(self, path: str) -> None:
63
+ super().__init__(path, {pascal})
64
+
65
+ # TODO: add custom query methods here
66
+ # async def find_by_name(self, name: str) -> list[{pascal}]:
67
+ # rows = self._fetch(
68
+ # f"SELECT * FROM {{self._table}} WHERE deleted_at IS NULL AND lower(name) LIKE ?",
69
+ # [f"%{{name.lower()}}%"],
70
+ # )
71
+ # return [self._row_to_entity(r) for r in rows]
72
+ """,
73
+ }
74
+
75
+ # ── repository.py re-export template ─────────────────────────────────────────
76
+
77
+ REPO_REEXPORT: dict[str, str] = {
78
+ "memory": """\
79
+ from adapters.output.memory.repositories.{snake}_repository import InMemory{pascal}Repository
80
+
81
+ __all__ = ["InMemory{pascal}Repository"]
82
+ """,
83
+ "mongodb": """\
84
+ from adapters.output.mongodb.repositories.{snake}_repository import MongoDB{pascal}Repository
85
+
86
+ __all__ = ["MongoDB{pascal}Repository"]
87
+ """,
88
+ "duckdb": """\
89
+ from adapters.output.duckdb.repositories.{snake}_repository import DuckDB{pascal}Repository
90
+
91
+ __all__ = ["DuckDB{pascal}Repository"]
92
+ """,
93
+ }
94
+
95
+ # ── Container template (full file, regenerated with all installed adapters) ───
96
+
97
+ _CONTAINER_HEADER = """\
98
+ from __future__ import annotations
99
+
100
+ from application.services.{snake}_service import {pascal}Service
101
+ from arclith import Arclith, AdapterRegistry
102
+ from arclith.infrastructure.config import AppConfig
103
+ from arclith.domain.ports.logger import Logger
104
+ from domain.models.{snake} import {pascal}
105
+ from domain.ports.output.{snake}_repository import {pascal}Repository
106
+
107
+ """
108
+
109
+ _CONTAINER_FACTORY: dict[str, str] = {
110
+ "memory": """\
111
+ def _build_memory(cfg: AppConfig, log: Logger) -> {pascal}Repository:
112
+ from adapters.output.memory.repository import InMemory{pascal}Repository
113
+ return InMemory{pascal}Repository()
114
+
115
+ """,
116
+ "mongodb": """\
117
+ def _build_mongodb(cfg: AppConfig, log: Logger) -> {pascal}Repository:
118
+ from adapters.output.mongodb.repository import MongoDB{pascal}Repository
119
+ from arclith.adapters.output.mongodb.config import MongoDBConfig
120
+ mongo = cfg.adapters.mongodb
121
+ if mongo is None:
122
+ raise RuntimeError("MongoDB settings are required when repository=mongodb")
123
+ return MongoDB{pascal}Repository(MongoDBConfig(uri=mongo.uri, db_name=mongo.db_name), log)
124
+
125
+ """,
126
+ "duckdb": """\
127
+ def _build_duckdb(cfg: AppConfig, log: Logger) -> {pascal}Repository:
128
+ from adapters.output.duckdb.repository import DuckDB{pascal}Repository
129
+ duckdb = cfg.adapters.duckdb
130
+ if duckdb is None:
131
+ raise RuntimeError("DuckDB settings are required when repository=duckdb")
132
+ return DuckDB{pascal}Repository(duckdb.path)
133
+
134
+ """,
135
+ }
136
+
137
+ _CONTAINER_FOOTER = """\
138
+ _registry: AdapterRegistry[{pascal}] = (
139
+ AdapterRegistry()
140
+ {registrations}
141
+ )
142
+
143
+
144
+ def build_{snake}_service(arclith: Arclith) -> tuple[{pascal}Service, Logger]:
145
+ arclith.logger.info("🗄️ Repository adapter selected", adapter=arclith.config.adapters.repository)
146
+ repo: {pascal}Repository = _registry.build(arclith.config, arclith.logger)
147
+ return {pascal}Service(repo, arclith.logger, arclith.config.soft_delete.retention_days), arclith.logger
148
+ """
149
+
150
+
151
+ def render_container(pascal: str, snake: str, installed_adapters: list[str]) -> str:
152
+ """Generate the full container file content for a given entity and its adapters."""
153
+ # memory is always included (arclith built-in, needs no extra files)
154
+ adapters = list(dict.fromkeys(["memory"] + installed_adapters))
155
+
156
+ header = _CONTAINER_HEADER.format(pascal=pascal, snake=snake)
157
+ factories = "".join(
158
+ _CONTAINER_FACTORY[a].format(pascal=pascal, snake=snake)
159
+ for a in adapters
160
+ if a in _CONTAINER_FACTORY
161
+ )
162
+ registrations = "\n".join(
163
+ f" .register(\"{a}\", _build_{a})"
164
+ for a in adapters
165
+ if a in _CONTAINER_FACTORY
166
+ )
167
+ footer = _CONTAINER_FOOTER.format(pascal=pascal, snake=snake, registrations=registrations)
168
+ return header + factories + footer
169
+
170
+
171
+ def render(template: str, vars: dict[str, Any]) -> str:
172
+ return template.format(**vars)
173
+
@@ -0,0 +1,288 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.prompt import Confirm, Prompt
9
+ from rich.table import Table
10
+
11
+ from .adapter_templates import (
12
+ CONFIG_YAML,
13
+ REPO_PYTHON,
14
+ REPO_REEXPORT,
15
+ SUPPORTED_ADAPTERS,
16
+ render,
17
+ render_container,
18
+ )
19
+ from .entity_scanner import EntityInfo, scan_entities, scan_installed_adapters
20
+
21
+ console = Console()
22
+
23
+
24
+ # ── Entry point ───────────────────────────────────────────────────────────────
25
+
26
+ def add_adapter_cmd() -> None:
27
+ """Wizard interactif pour scaffolder un nouvel adapter output."""
28
+ project_dir = Path.cwd()
29
+
30
+ _assert_arclith_project(project_dir)
31
+
32
+ adapter = _prompt_adapter_type()
33
+ entities = _prompt_entities(project_dir)
34
+ params = _prompt_adapter_params(adapter, project_dir)
35
+ activate = Confirm.ask(
36
+ f"\n [bold]Activer[/bold] [green]{adapter}[/green] maintenant ?",
37
+ default=True,
38
+ )
39
+
40
+ _show_recap(project_dir, adapter, entities, params, activate)
41
+
42
+ if not Confirm.ask("\n [bold]Confirmer la génération ?[/bold]", default=True):
43
+ console.print("[yellow]Annulé.[/yellow]")
44
+ raise typer.Exit(0)
45
+
46
+ _generate(project_dir, adapter, entities, params, activate)
47
+
48
+
49
+ # ── Validation ────────────────────────────────────────────────────────────────
50
+
51
+ def _assert_arclith_project(project_dir: Path) -> None:
52
+ if not (project_dir / "domain" / "models").exists():
53
+ console.print(
54
+ "[red]✗[/red] Aucun dossier [bold]domain/models/[/bold] trouvé.\n"
55
+ " Exécutez [bold]arclith-cli add-adapter[/bold] depuis la racine d'un projet arclith."
56
+ )
57
+ raise typer.Exit(1)
58
+ if not (project_dir / "config" / "adapters").exists():
59
+ console.print(
60
+ "[red]✗[/red] Aucun dossier [bold]config/adapters/[/bold] trouvé.\n"
61
+ " Le projet doit utiliser la structure [bold]config/[/bold] directory."
62
+ )
63
+ raise typer.Exit(1)
64
+
65
+
66
+ # ── Step 1 : adapter type ─────────────────────────────────────────────────────
67
+
68
+ def _prompt_adapter_type() -> str:
69
+ console.print("\n[bold]① Type d'adapter[/bold]")
70
+ for i, name in enumerate(SUPPORTED_ADAPTERS, 1):
71
+ console.print(f" [bold cyan]{i}[/bold cyan] {name}")
72
+
73
+ while True:
74
+ raw = Prompt.ask("\n Votre choix [dim](numéro ou nom)[/dim]").strip()
75
+ if raw.isdigit():
76
+ idx = int(raw) - 1
77
+ if 0 <= idx < len(SUPPORTED_ADAPTERS):
78
+ return SUPPORTED_ADAPTERS[idx]
79
+ elif raw in SUPPORTED_ADAPTERS:
80
+ return raw
81
+ console.print(f" [red]Choix invalide.[/red] Entrez 1-{len(SUPPORTED_ADAPTERS)} ou le nom.")
82
+
83
+
84
+ # ── Step 2 : entity selection ─────────────────────────────────────────────────
85
+
86
+ def _prompt_entities(project_dir: Path) -> list[EntityInfo]:
87
+ entities = scan_entities(project_dir)
88
+ if not entities:
89
+ console.print("[red]✗[/red] Aucune entité trouvée dans [bold]domain/models/[/bold].")
90
+ raise typer.Exit(1)
91
+
92
+ console.print("\n[bold]② Entité(s) cible(s)[/bold]")
93
+ for i, e in enumerate(entities, 1):
94
+ console.print(f" [bold cyan]{i}[/bold cyan] {e.pascal} [dim]({e.snake})[/dim]")
95
+ console.print(f" [bold cyan]{len(entities) + 1}[/bold cyan] [italic]toutes[/italic]")
96
+
97
+ while True:
98
+ raw = Prompt.ask("\n Votre choix [dim](numéro(s) séparés par virgule, ou nom)[/dim]").strip()
99
+ selected = _parse_entity_choice(raw, entities)
100
+ if selected is not None:
101
+ return selected
102
+ console.print(" [red]Choix invalide.[/red]")
103
+
104
+
105
+ def _parse_entity_choice(raw: str, entities: list[EntityInfo]) -> list[EntityInfo] | None:
106
+ all_idx = len(entities) + 1
107
+ parts = [p.strip() for p in raw.split(",") if p.strip()]
108
+ result: list[EntityInfo] = []
109
+ for part in parts:
110
+ if part.isdigit():
111
+ idx = int(part)
112
+ if idx == all_idx:
113
+ return list(entities)
114
+ if 1 <= idx <= len(entities):
115
+ e = entities[idx - 1]
116
+ if e not in result:
117
+ result.append(e)
118
+ else:
119
+ return None
120
+ else:
121
+ matched = [e for e in entities if e.pascal == part or e.snake == part]
122
+ if not matched:
123
+ return None
124
+ for e in matched:
125
+ if e not in result:
126
+ result.append(e)
127
+ return result or None
128
+
129
+
130
+ # ── Step 3 : adapter-specific params ─────────────────────────────────────────
131
+
132
+ def _prompt_adapter_params(adapter: str, project_dir: Path) -> dict:
133
+ console.print(f"\n[bold]③ Paramètres [green]{adapter}[/green][/bold]")
134
+
135
+ if adapter == "mongodb":
136
+ project_name = project_dir.name
137
+ db_name = Prompt.ask(
138
+ " db_name",
139
+ default=project_name,
140
+ ).strip()
141
+ multitenant = Confirm.ask(" multitenant", default=False)
142
+ return {"db_name": db_name, "multitenant": multitenant}
143
+
144
+ if adapter == "duckdb":
145
+ path = Prompt.ask(" path", default="data/").strip()
146
+ return {"path": path}
147
+
148
+ console.print(" [dim](aucun paramètre requis)[/dim]")
149
+ return {}
150
+
151
+
152
+ # ── Step 4 : recap ────────────────────────────────────────────────────────────
153
+
154
+ def _show_recap(
155
+ project_dir: Path,
156
+ adapter: str,
157
+ entities: list[EntityInfo],
158
+ params: dict,
159
+ activate: bool,
160
+ ) -> None:
161
+ installed = scan_installed_adapters(project_dir)
162
+ files = _list_generated_files(project_dir, adapter, entities, installed)
163
+
164
+ table = Table(show_header=True, header_style="bold blue", box=None, padding=(0, 2))
165
+ table.add_column("Fichier")
166
+ table.add_column("Action", style="dim")
167
+
168
+ for path, action in files:
169
+ style = "yellow" if action == "remplacé ⚠" else "green"
170
+ table.add_row(str(path.relative_to(project_dir)), f"[{style}]{action}[/{style}]")
171
+
172
+ if activate:
173
+ cfg_path = project_dir / "config" / "adapters" / "adapters.yaml"
174
+ table.add_row(
175
+ str(cfg_path.relative_to(project_dir)),
176
+ "[cyan]mis à jour (repository)[/cyan]",
177
+ )
178
+
179
+ console.print()
180
+ console.print(Panel(table, title=f"[bold]Récapitulatif — adapter [green]{adapter}[/green][/bold]"))
181
+
182
+
183
+ def _list_generated_files(
184
+ project_dir: Path,
185
+ adapter: str,
186
+ entities: list[EntityInfo],
187
+ installed: list[str],
188
+ ) -> list[tuple[Path, str]]:
189
+ files: list[tuple[Path, str]] = []
190
+
191
+ if adapter != "memory" and CONFIG_YAML.get(adapter):
192
+ cfg = project_dir / "config" / "adapters" / "output" / f"{adapter}.yaml"
193
+ files.append((cfg, "remplacé ⚠" if cfg.exists() else "créé"))
194
+
195
+ for entity in entities:
196
+ base = project_dir / "adapters" / "output" / adapter
197
+ repo_dir = base / "repositories"
198
+ repo_file = repo_dir / f"{entity.snake}_repository.py"
199
+ reexport = base / "repository.py"
200
+ init = base / "__init__.py"
201
+ container = project_dir / "infrastructure" / "containers" / f"{entity.snake}_container.py"
202
+
203
+ files.append((init, "remplacé ⚠" if init.exists() else "créé"))
204
+ files.append((repo_file, "remplacé ⚠" if repo_file.exists() else "créé"))
205
+ files.append((reexport, "remplacé ⚠" if reexport.exists() else "créé"))
206
+ files.append((container, "remplacé ⚠" if container.exists() else "créé"))
207
+
208
+ return files
209
+
210
+
211
+ # ── Step 5 : generate ─────────────────────────────────────────────────────────
212
+
213
+ def _generate(
214
+ project_dir: Path,
215
+ adapter: str,
216
+ entities: list[EntityInfo],
217
+ params: dict,
218
+ activate: bool,
219
+ ) -> None:
220
+ installed = scan_installed_adapters(project_dir)
221
+ if adapter not in installed:
222
+ installed = sorted(installed + [adapter])
223
+
224
+ # Config YAML (skip memory — no config needed)
225
+ if adapter != "memory":
226
+ yaml_content = CONFIG_YAML.get(adapter, "")
227
+ if yaml_content:
228
+ cfg_path = project_dir / "config" / "adapters" / "output" / f"{adapter}.yaml"
229
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
230
+ cfg_path.write_text(render(yaml_content, params))
231
+ console.print(f"[green]✓[/green] {cfg_path.relative_to(project_dir)}")
232
+
233
+ for entity in entities:
234
+ vars = {"pascal": entity.pascal, "snake": entity.snake, **params}
235
+ base = project_dir / "adapters" / "output" / adapter
236
+ repo_dir = base / "repositories"
237
+ repo_dir.mkdir(parents=True, exist_ok=True)
238
+
239
+ # __init__.py
240
+ init_file = base / "__init__.py"
241
+ if not init_file.exists():
242
+ init_file.write_text("")
243
+ # repositories/__init__.py
244
+ repo_init_file = repo_dir / "__init__.py"
245
+ if not repo_init_file.exists():
246
+ repo_init_file.write_text("")
247
+ console.print(f"[green]✓[/green] {repo_init_file.relative_to(project_dir)}")
248
+
249
+ # Repository subclass
250
+ repo_file = repo_dir / f"{entity.snake}_repository.py"
251
+ repo_file.write_text(render(REPO_PYTHON[adapter], vars))
252
+ console.print(f"[green]✓[/green] {repo_file.relative_to(project_dir)}")
253
+
254
+ # Re-export
255
+ reexport = base / "repository.py"
256
+ reexport.write_text(render(REPO_REEXPORT[adapter], vars))
257
+ console.print(f"[green]✓[/green] {reexport.relative_to(project_dir)}")
258
+
259
+ # Container (full regeneration)
260
+ container = project_dir / "infrastructure" / "containers" / f"{entity.snake}_container.py"
261
+ existed = container.exists()
262
+ container.parent.mkdir(parents=True, exist_ok=True)
263
+ container.write_text(render_container(entity.pascal, entity.snake, installed))
264
+ action = "[yellow]remplacé ⚠[/yellow]" if existed else "[green]créé[/green]"
265
+ console.print(f"{action} {container.relative_to(project_dir)}")
266
+
267
+ # Activate: update config/adapters/adapters.yaml
268
+ if activate:
269
+ _update_active_adapter(project_dir, adapter)
270
+
271
+ console.print(f"\n[bold green]✓ Adapter [cyan]{adapter}[/cyan] scaffoldé avec succès.[/bold green]")
272
+
273
+
274
+ def _update_active_adapter(project_dir: Path, adapter: str) -> None:
275
+ import re
276
+ cfg = project_dir / "config" / "adapters" / "adapters.yaml"
277
+ if not cfg.exists():
278
+ cfg.parent.mkdir(parents=True, exist_ok=True)
279
+ cfg.write_text(f"repository: {adapter}\n")
280
+ else:
281
+ text = cfg.read_text()
282
+ if re.search(r"(?m)^repository:", text):
283
+ text = re.sub(r"(?m)^(repository:\s*).*$", rf"\g<1>{adapter}", text)
284
+ else:
285
+ text = text.rstrip("\n") + f"\nrepository: {adapter}\n"
286
+ cfg.write_text(text)
287
+ console.print(f"[cyan]↺[/cyan] config/adapters/adapters.yaml → repository: {adapter}")
288
+
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class EntityInfo:
10
+ pascal: str # Ingredient
11
+ snake: str # ingredient
12
+ file_path: Path
13
+
14
+
15
+ def scan_entities(project_dir: Path) -> list[EntityInfo]:
16
+ """Scan domain/models/*.py via AST to find Entity subclasses.
17
+
18
+ Extracts any class that directly names 'Entity' as a base.
19
+ Parsing errors are silently skipped so a broken file never blocks the wizard.
20
+ """
21
+ models_dir = project_dir / "domain" / "models"
22
+ if not models_dir.exists():
23
+ return []
24
+
25
+ entities: list[EntityInfo] = []
26
+ for py_file in sorted(models_dir.glob("*.py")):
27
+ if py_file.name.startswith("_"):
28
+ continue
29
+ try:
30
+ tree = ast.parse(py_file.read_text(encoding="utf-8"))
31
+ except (SyntaxError, UnicodeDecodeError):
32
+ continue
33
+ for node in ast.walk(tree):
34
+ if not isinstance(node, ast.ClassDef):
35
+ continue
36
+ base_names = {
37
+ b.id if isinstance(b, ast.Name)
38
+ else b.attr if isinstance(b, ast.Attribute)
39
+ else ""
40
+ for b in node.bases
41
+ }
42
+ if "Entity" in base_names:
43
+ entities.append(EntityInfo(
44
+ pascal=node.name,
45
+ snake=_to_snake(node.name),
46
+ file_path=py_file,
47
+ ))
48
+ return entities
49
+
50
+
51
+ def scan_installed_adapters(project_dir: Path) -> list[str]:
52
+ """Return adapter names found under adapters/output/ (subdirectory names)."""
53
+ output_dir = project_dir / "adapters" / "output"
54
+ if not output_dir.exists():
55
+ return []
56
+ return sorted(
57
+ p.name for p in output_dir.iterdir()
58
+ if p.is_dir() and not p.name.startswith("_")
59
+ )
60
+
61
+
62
+ def _to_snake(pascal: str) -> str:
63
+ import re
64
+ s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", pascal)
65
+ s = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s)
66
+ return s.lower()
67
+
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+
10
+ console = Console()
11
+
12
+
13
+ def export_config_cmd(
14
+ output: Annotated[
15
+ Path,
16
+ typer.Option("--output", "-o", help="Chemin du fichier YAML généré"),
17
+ ] = Path("config.yaml"),
18
+ ) -> None:
19
+ """Générer un [bold]config.yaml[/bold] unifié depuis le dossier [bold]config/[/bold].
20
+
21
+ Utile pour le déploiement Kubernetes — le fichier généré peut être monté
22
+ directement comme [bold]ConfigMap[/bold]. Arclith l'accepte en lecture via
23
+ [dim]Arclith("config.yaml")[/dim] au même titre que le dossier [dim]config/[/dim].
24
+ """
25
+ project_dir = Path.cwd()
26
+ config_dir = project_dir / "config"
27
+
28
+ if not config_dir.is_dir():
29
+ console.print(
30
+ "[red]✗[/red] Aucun dossier [bold]config/[/bold] trouvé.\n"
31
+ " Exécutez depuis la racine d'un projet arclith."
32
+ )
33
+ raise typer.Exit(1)
34
+
35
+ output_path = output if output.is_absolute() else project_dir / output
36
+
37
+ if output_path.exists():
38
+ from rich.prompt import Confirm
39
+ if not Confirm.ask(f" [yellow]{output_path.relative_to(project_dir)}[/yellow] existe déjà. Écraser ?", default=True):
40
+ console.print("[yellow]Annulé.[/yellow]")
41
+ raise typer.Exit(0)
42
+
43
+ try:
44
+ from arclith.infrastructure.config import export_config_yaml
45
+ export_config_yaml(config_dir, output_path)
46
+ except Exception as exc:
47
+ console.print(f"[red]✗ Erreur :[/red] {exc}")
48
+ raise typer.Exit(1) from exc
49
+
50
+ try:
51
+ rel = output_path.relative_to(project_dir)
52
+ display_path = str(rel)
53
+ except ValueError:
54
+ # output_path is outside project_dir (e.g., /tmp/config.yaml)
55
+ display_path = str(output_path)
56
+
57
+ console.print(
58
+ Panel.fit(
59
+ f"[green]✓[/green] [bold]{display_path}[/bold] généré depuis [dim]config/[/dim]\n\n"
60
+ f" [bold cyan]Kubernetes[/bold cyan] Monter ce fichier comme ConfigMap\n"
61
+ f" [bold cyan]Arclith[/bold cyan] [dim]Arclith(\"{display_path}\")[/dim] ← identique à [dim]Arclith(\"config/\")[/dim]\n\n"
62
+ f" [dim]⚠ Fichier généré — ne pas éditer manuellement.[/dim]\n"
63
+ f" [dim] Ajouter [bold]config.yaml[/bold] à .gitignore[/dim]",
64
+ border_style="green",
65
+ title="[bold]export-config[/bold]",
66
+ )
67
+
68
+ )
69
+