arclith-cli 0.12.0__tar.gz → 0.13.0__tar.gz

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 (33) hide show
  1. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/PKG-INFO +3 -3
  2. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/README.md +66 -14
  3. arclith_cli-0.13.0/arclith_cli/__init__.py +1 -0
  4. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/add_adapter.py +219 -14
  5. arclith_cli-0.13.0/arclith_cli/capabilities.py +1516 -0
  6. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/init_project.py +40 -2
  7. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/main.py +32 -1
  8. arclith_cli-0.13.0/arclith_cli/runtime_templates.py +173 -0
  9. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/pyproject.toml +2 -2
  10. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/tests/e2e_manual.sh +1 -1
  11. arclith_cli-0.13.0/tests/test_add_adapter.py +1905 -0
  12. arclith_cli-0.13.0/tests/test_capabilities.py +660 -0
  13. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/tests/test_core_scaffold.py +32 -1
  14. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/tests/test_e2e_scaffold.py +33 -2
  15. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/uv.lock +5 -3
  16. arclith_cli-0.12.0/arclith_cli/__init__.py +0 -1
  17. arclith_cli-0.12.0/arclith_cli/capabilities.py +0 -681
  18. arclith_cli-0.12.0/tests/test_add_adapter.py +0 -490
  19. arclith_cli-0.12.0/tests/test_capabilities.py +0 -205
  20. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/.gitignore +0 -0
  21. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/LICENSE +0 -0
  22. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/Makefile +0 -0
  23. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/adapter_templates.py +0 -0
  24. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/core_scaffold.py +0 -0
  25. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/entity_scanner.py +0 -0
  26. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/export_config.py +0 -0
  27. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/project_paths.py +0 -0
  28. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/rename.py +0 -0
  29. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/scaffold.py +0 -0
  30. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/updater.py +0 -0
  31. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/tests/__init__.py +0 -0
  32. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/tests/test_project_paths.py +0 -0
  33. {arclith_cli-0.12.0 → arclith_cli-0.13.0}/tests/test_rename.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: arclith-cli
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: CLI scaffolding tool for arclith — hexagonal architecture framework
5
5
  Author-email: Killian KOPP <killiankopp@gmail.com>
6
6
  License: Apache License
@@ -186,7 +186,7 @@ License: Apache License
186
186
  License-File: LICENSE
187
187
  Keywords: arclith,cli,ddd,hexagonal-architecture,scaffold
188
188
  Requires-Python: >=3.13
189
- Requires-Dist: arclith>=0.15.0
189
+ Requires-Dist: arclith>=0.16.0
190
190
  Requires-Dist: httpx>=0.27.0
191
191
  Requires-Dist: rich>=13.0.0
192
192
  Requires-Dist: typer>=0.15.0
@@ -13,7 +13,8 @@ uv tool install "git+https://github.com/karned-rekipe/arclith.git#subdirectory=c
13
13
  ### `init` — Initialiser un projet minimal
14
14
 
15
15
  Crée un projet Arclith vide de métier, avec le layout canonique `src/<package>/...`, une
16
- configuration minimale et un `main.py` prêt à recevoir les adapters.
16
+ configuration minimale, un `main.py` prêt à recevoir les adapters et le runtime Docker standard
17
+ (`Dockerfile`, `.dockerignore`, `arclith-run`).
17
18
 
18
19
  ```bash
19
20
  # Mode interactif
@@ -49,7 +50,9 @@ arclith-cli new MealPlan meal-plan-service --dir ~/projects --port 8500
49
50
  | `--dir` / `-d` | `.` | Répertoire parent |
50
51
  | `--ref` | `main` | Branche/tag du template |
51
52
 
52
- Le projet généré utilise un layout `src/<package>/...` pour le code applicatif et un dossier `config/` structuré par adapter (voir section [Configuration](#configuration)).
53
+ Le projet généré utilise un layout `src/<package>/...` pour le code applicatif et un dossier
54
+ `config/` structuré par adapter (voir section [Configuration](#configuration)). Le Dockerfile du
55
+ template est régénéré côté CLI pour appliquer le contrat `runtime/docker-image` courant.
53
56
 
54
57
  ---
55
58
 
@@ -132,7 +135,7 @@ arclith-cli add-adapter
132
135
  Mode direct, utile pour CI, scripts de migration ou commandes reproductibles :
133
136
 
134
137
  ```bash
135
- arclith-cli add-adapter --adapter mongodb --entity Recipe --db-name my_recipe_service --yes
138
+ arclith-cli add-adapter --adapter mongodb --entity Recipe --db-name my_recipe_service --param collection_name=recipes --yes
136
139
  arclith-cli add-adapter --adapter duckdb --all-entities --path data/ --no-activate --yes
137
140
  arclith-cli add-adapter --adapter mariadb --entity Recipe --param database=my_recipe_service --param user=app --yes
138
141
  arclith-cli add-adapter --capability api --adapter fastapi --param port=8080 --yes
@@ -141,17 +144,23 @@ arclith-cli add-adapter --capability llm --adapter lmstudio --param model_name=q
141
144
  arclith-cli add-adapter --capability agent --adapter langgraph --param graph_name=recipe_agent --yes
142
145
  arclith-cli add-adapter --capability observability --adapter langsmith
143
146
  arclith-cli add-adapter --capability observability --adapter opentelemetry --param service_name=my_recipe_service --yes
147
+ arclith-cli add-adapter --capability runtime --adapter docker-image --yes
148
+ arclith-cli add-adapter --capability cache --adapter memory --yes
149
+ arclith-cli add-adapter --capability cache --adapter redis --param redis_url=redis://redis:6379 --yes
144
150
  arclith-cli add-adapter --capability repository --adapter memory --entity Recipe --yes
145
151
  ```
146
152
 
147
153
  **Étapes du wizard :**
148
154
 
149
- 1. **Type d'adapter** — selon la capacité : `memory` · `mongodb` · `duckdb` · `mariadb` · `fastapi` · `fastmcp` · `lmstudio` · `openai` · `anthropic` · `langgraph` · `langsmith` · `opentelemetry`
150
- 2. **Entité(s) cible(s)** — détectées automatiquement pour les adapters entity-scoped ; ignorées pour les transports globaux, `llm/*`, `agent/langgraph` et les adapters d'observability
155
+ 1. **Type d'adapter** — selon la capacité : `memory` · `mongodb` · `duckdb` · `mariadb` · `fastapi` · `fastmcp` · `rabbitmq` · `docker-image` · `lmstudio` · `openai` · `anthropic` · `langgraph` · `langsmith` · `opentelemetry`
156
+ 2. **Entité(s) cible(s)** — détectées automatiquement pour les adapters entity-scoped ; ignorées pour les transports globaux, `cache/*`, `llm/*`, `agent/langgraph`, `runtime/docker-image` et les adapters d'observability
151
157
  3. **Paramètres** — questions spécifiques à l'adapter :
152
- - `mongodb` → `db_name`, `multitenant`
158
+ - `mongodb` → `db_name`, `collection_name`, `multitenant`
153
159
  - `duckdb` → `path`
154
160
  - `mariadb` → `host`, `port`, `database`, `user`, `driver`, `table_prefix`
161
+ (`url` et `password` sont mappés via `config/secrets.yaml`)
162
+ - `cache/memory` → `jwks_ttl`, `tenant_uri_ttl`
163
+ - `cache/redis` → `redis_url`, `jwks_ttl`, `tenant_uri_ttl`
155
164
  - `fastapi` → `host`, `port`, `reload`
156
165
  - `fastmcp` → `host`, `port`
157
166
  - `lmstudio` → `model_name`, `base_url`, `api_key`
@@ -159,15 +168,17 @@ arclith-cli add-adapter --capability repository --adapter memory --entity Recipe
159
168
  - `anthropic` → `model_name`, `ANTHROPIC_API_KEY`
160
169
  - `langgraph` → `graph_name`
161
170
  - `langsmith` → `tracing`, `project`, `endpoint`, `LANGSMITH_API_KEY`
162
- - `opentelemetry` → `service_name`, `endpoint`, `protocol`, `traces`, `metrics`, `instrument_fastapi`
163
- - `memory` → aucun paramètre
164
- 4. **Activation** — met à jour `config/adapters/adapters.yaml` pour les capacités activables (`repository: <adapter>` ou `observability.enabled: [<adapter>, ...]`) ; `api/fastapi`, `mcp/fastmcp`, `llm/*` et `agent/langgraph` sont exposés par leurs fichiers de configuration scopés
171
+ - `opentelemetry` → `service_name`, `endpoint`, `traces_endpoint`, `metrics_endpoint`, `protocol`, `traces`, `metrics`, `instrument_fastapi`
172
+ - `command-bus/rabbitmq` → `url`, `exchange`, `exchange_type`, `queue`, `routing_key`, `prefetch`, `consumer_name`, `concurrency`, `publisher_confirms`, `durable`, `retry_enabled`, `retry_requeue`, `dead_letter_exchange`, `dead_letter_routing_key`
173
+ - `runtime/docker-image` → `uv_version`, `api_port`, `mcp_port`, `probe_port`, `agent_port`
174
+ - `repository/memory` → aucun paramètre
175
+ 4. **Activation** — met à jour `config/adapters/adapters.yaml` pour les capacités activables (`repository: <adapter>` ou `observability.enabled: [<adapter>, ...]`) ; `api/fastapi`, `mcp/fastmcp`, `cache/*`, `llm/*`, `agent/langgraph`, `command-bus/rabbitmq` et `runtime/docker-image` sont exposés par leurs fichiers dédiés
165
176
  5. **Récapitulatif** — liste des fichiers créés ou remplacés avant confirmation
166
177
 
167
178
  | Option | Défaut | Description |
168
179
  |--------|--------|-------------|
169
- | `--capability` | `repository` | Capacité cible du catalogue standardisé (`repository`, `api`, `mcp`, `llm`, `agent`, `observability`) |
170
- | `--adapter` / `-a` | interactif | Adapter du catalogue : `memory`, `mongodb`, `duckdb`, `mariadb`, `fastapi`, `fastmcp`, `lmstudio`, `openai`, `anthropic`, `langgraph`, `langsmith`, `opentelemetry` |
180
+ | `--capability` | `repository` | Capacité cible du catalogue standardisé (`repository`, `cache`, `api`, `mcp`, `http`, `command-bus`, `runtime`, `llm`, `agent`, `observability`) |
181
+ | `--adapter` / `-a` | interactif | Adapter du catalogue : `memory`, `mongodb`, `duckdb`, `mariadb`, `fastapi`, `fastmcp`, `idempotency`, `etag`, `cache-control`, `rabbitmq`, `docker-image`, `lmstudio`, `openai`, `anthropic`, `langgraph`, `langsmith`, `opentelemetry` |
171
182
  | `--entity` / `-e` | auto si une seule entité | Entité cible, liste séparée par virgule acceptée |
172
183
  | `--all-entities` | `false` | Génère l'adapter pour toutes les entités détectées |
173
184
  | `--activate/--no-activate` | `--activate` | Met à jour `config/adapters/adapters.yaml` quand la capacité expose une clé d'activation |
@@ -189,6 +200,20 @@ src/<package>/infrastructure/containers/<entity>_container.py # RepositoryRegis
189
200
 
190
201
  > ⚠️ `src/<package>/infrastructure/containers/<entity>_container.py` est **régénéré intégralement** si le fichier existe déjà — un avertissement est affiché dans le récapitulatif.
191
202
 
203
+ **Runtime Docker :**
204
+
205
+ ```bash
206
+ arclith-cli add-adapter --capability runtime --adapter docker-image --yes
207
+ uv lock
208
+ docker build -t my-recipe-service:local .
209
+ docker run --rm -p 8000:8000 -p 9000:9000 my-recipe-service:local api
210
+ ```
211
+
212
+ L'adapter `runtime/docker-image` génère `Dockerfile`, `.dockerignore` et `arclith-run`. Une seule
213
+ image peut démarrer `api`, `mcp_http`, `mcp_sse`, `bus`, `agent` ou `all` par argument ou via
214
+ `ARCLITH_RUNTIME_MODE`. Les secrets restent hors build; `.env`, `secrets.yaml` et les clés privées
215
+ sont exclus du contexte Docker.
216
+
192
217
  **LangGraph / LangSmith :**
193
218
 
194
219
  ```bash
@@ -200,8 +225,17 @@ uv run langgraph dev --no-browser --allow-blocking --port 2024
200
225
  ```
201
226
 
202
227
  L'adapter `llm/lmstudio` génère `config/adapters/outbound/lm.yaml`, chargé dans
203
- `AppConfig.adapters.lm`. Les adapters `llm/openai` et `llm/anthropic` génèrent aussi un mapping
204
- `config/secrets.yaml` vers `OPENAI_API_KEY` ou `ANTHROPIC_API_KEY`.
228
+ `AppConfig.adapters.lm`. Adapter `model_name` au modèle chargé dans LM Studio et utiliser
229
+ `host.docker.internal` comme `base_url` si le projet tourne dans Docker alors que LM Studio tourne
230
+ sur l'hôte. Les adapters `llm/openai` et `llm/anthropic` génèrent aussi un mapping
231
+ `config/secrets.yaml` vers `OPENAI_API_KEY` ou `ANTHROPIC_API_KEY`; la clé réelle reste dans `.env`
232
+ local gitignoré, l'environnement runtime ou Vault.
233
+ Utiliser `llm/anthropic` pour Claude via le provider Anthropic; utiliser `llm/openai` pour OpenAI,
234
+ LM Studio ou tout endpoint OpenAI-compatible avec `base_url`.
235
+
236
+ L'adapter `repository/mongodb` génère `config/adapters/outbound/mongodb.yaml` avec `uri: null`, puis
237
+ mappe `adapters.mongodb.uri` vers `MONGODB_URI` dans `config/secrets.yaml`. L'URI réelle reste dans
238
+ l'environnement, un fichier local de secrets ou Vault selon le resolver choisi.
205
239
 
206
240
  L'adapter `agent/langgraph` génère `langgraph.json`, `config/adapters/inbound/langgraph.yaml` et
207
241
  `src/<package>/adapters/inbound/langgraph/agent.py`. Le projet ne modifie ensuite que ce fichier pour
@@ -224,6 +258,9 @@ jour `.env`, l'ajoute à `observability.enabled` et branche l'instrumentation Fa
224
258
  `Arclith.fastapi()` construit l'application. Il peut être activé en même temps que LangSmith.
225
259
  Le fichier `opentelemetry.yaml` ne porte pas de flag `enabled`: l'activation se fait uniquement dans
226
260
  `observability.enabled`.
261
+ L'endpoint global est utilisé par défaut; `traces_endpoint` et `metrics_endpoint` peuvent cibler des
262
+ routes OTLP distinctes. Pour taguer l'environnement, définir
263
+ `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=local` dans l'environnement runtime.
227
264
 
228
265
  Parcours complet avec entité, API, LangGraph, LangSmith et LM Studio:
229
266
  [`docs/agent-quickstart.md`](../docs/agent-quickstart.md).
@@ -310,6 +347,19 @@ config/
310
347
  cache.yaml # cache: { backend, redis_url, … }
311
348
  ```
312
349
 
350
+ `cache/memory` génère `config/adapters/inbound/cache.yaml` avec `backend: memory` et les TTL JWKS /
351
+ tenant. Ce cache est strictement local au processus Python: il suffit pour le développement, les
352
+ tests et un worker unique. Passer à Redis dès qu'il faut partager le cache entre plusieurs workers,
353
+ réplicas, ou processus séparés API/MCP/agent.
354
+
355
+ `cache/redis` génère le même fichier avec `backend: redis`, mappe `cache.redis_url` vers
356
+ `REDIS_URL` dans `config/secrets.yaml` et écrit la valeur fournie dans `.env` local gitignoré.
357
+ Installer l'extra avant de lancer le service:
358
+
359
+ ```bash
360
+ uv add "arclith[cache]"
361
+ ```
362
+
313
363
  Pour changer l'adapter actif sans passer par le wizard :
314
364
 
315
365
  ```yaml
@@ -321,4 +371,6 @@ observability:
321
371
  - opentelemetry
322
372
  ```
323
373
 
324
- Pour MariaDB, ne committez pas le mot de passe. Mappez `adapters.mariadb.password` ou `adapters.mariadb.url` via `config/secrets.yaml`, un resolver `env` ou Vault.
374
+ Pour MariaDB, ne committez pas le mot de passe ni l'URL complète si elle contient des identifiants.
375
+ La CLI mappe `adapters.mariadb.password` vers `MARIADB_PASSWORD` et `adapters.mariadb.url` vers
376
+ `MARIADB_URL` dans `config/secrets.yaml`; remplacer le resolver `env` par Vault selon l'environnement.
@@ -0,0 +1 @@
1
+ __version__ = "0.13.0"
@@ -29,6 +29,7 @@ from .entity_scanner import EntityInfo, scan_entities, scan_installed_adapters
29
29
  from .project_paths import ProjectPaths, detect_project_paths
30
30
 
31
31
  console = Console()
32
+ _UV_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
32
33
 
33
34
 
34
35
  # ── Entry point ───────────────────────────────────────────────────────────────
@@ -274,7 +275,88 @@ def _resolve_adapter_params(
274
275
  value = _resolve_parameter(parameter, provided_values.get(parameter.name), project_dir, prompt_missing)
275
276
  resolved[parameter.name] = _render_parameter_value(parameter, value)
276
277
 
277
- return resolved
278
+ return _normalize_adapter_params(adapter, resolved)
279
+
280
+
281
+ def _normalize_adapter_params(adapter: AdapterSpec, params: dict[str, Any]) -> dict[str, Any]:
282
+ if adapter.capability == "http" and adapter.name == "cache-control":
283
+ return _normalize_cache_control_params(params)
284
+ if adapter.capability == "command-bus" and adapter.name == "rabbitmq":
285
+ return _normalize_rabbitmq_command_bus_params(params)
286
+ if adapter.capability == "runtime" and adapter.name == "docker-image":
287
+ return _normalize_docker_image_params(params)
288
+ return params
289
+
290
+
291
+ def _normalize_cache_control_params(params: dict[str, Any]) -> dict[str, Any]:
292
+ normalized = dict(params)
293
+ for name in ("get_single_max_age", "get_list_max_age"):
294
+ raw_value = str(normalized[name]).strip()
295
+ try:
296
+ value = int(raw_value)
297
+ except ValueError:
298
+ console.print(
299
+ f"[red]✗[/red] Valeur entière invalide pour [bold]{name}[/bold]: {raw_value}."
300
+ )
301
+ raise typer.Exit(1) from None
302
+ if value < 0:
303
+ console.print(
304
+ f"[red]✗[/red] Valeur invalide pour [bold]{name}[/bold]: {value}. "
305
+ "Utilisez une valeur >= 0."
306
+ )
307
+ raise typer.Exit(1)
308
+ normalized[name] = value
309
+ return normalized
310
+
311
+
312
+ def _normalize_rabbitmq_command_bus_params(params: dict[str, Any]) -> dict[str, Any]:
313
+ normalized = dict(params)
314
+ for name in ("prefetch", "concurrency"):
315
+ raw_value = str(normalized[name]).strip()
316
+ try:
317
+ value = int(raw_value)
318
+ except ValueError:
319
+ console.print(
320
+ f"[red]✗[/red] Valeur entière invalide pour [bold]{name}[/bold]: {raw_value}."
321
+ )
322
+ raise typer.Exit(1) from None
323
+ if value <= 0:
324
+ console.print(
325
+ f"[red]✗[/red] Valeur invalide pour [bold]{name}[/bold]: {value}. "
326
+ "Utilisez une valeur > 0."
327
+ )
328
+ raise typer.Exit(1)
329
+ normalized[name] = value
330
+ return normalized
331
+
332
+
333
+ def _normalize_docker_image_params(params: dict[str, Any]) -> dict[str, Any]:
334
+ normalized = dict(params)
335
+ uv_version = str(normalized["uv_version"]).strip()
336
+ if not _UV_VERSION_RE.fullmatch(uv_version):
337
+ console.print(
338
+ "[red]✗[/red] Version uv invalide: utilisez un token sans espace, accolade ou saut de ligne."
339
+ )
340
+ raise typer.Exit(1)
341
+ normalized["uv_version"] = uv_version
342
+
343
+ for name in ("api_port", "mcp_port", "probe_port", "agent_port"):
344
+ raw_value = str(normalized[name]).strip()
345
+ try:
346
+ value = int(raw_value)
347
+ except ValueError:
348
+ console.print(
349
+ f"[red]✗[/red] Port entier invalide pour [bold]{name}[/bold]: {raw_value}."
350
+ )
351
+ raise typer.Exit(1) from None
352
+ if value <= 0 or value > 65535:
353
+ console.print(
354
+ f"[red]✗[/red] Port invalide pour [bold]{name}[/bold]: {value}. "
355
+ "Utilisez une valeur entre 1 et 65535."
356
+ )
357
+ raise typer.Exit(1)
358
+ normalized[name] = value
359
+ return normalized
278
360
 
279
361
 
280
362
  def _assert_supported_params(adapter: AdapterSpec, extra_params: dict[str, str]) -> None:
@@ -318,8 +400,38 @@ def _resolve_parameter(
318
400
  resolved = provided_value.strip() if isinstance(provided_value, str) else ""
319
401
  string_default = _default_string_value(parameter, project_dir)
320
402
  if not resolved and prompt_missing:
321
- resolved = Prompt.ask(f" {parameter.prompt}", default=string_default, password=parameter.secret).strip()
322
- return resolved or string_default
403
+ prompt_kwargs: dict[str, Any] = {"password": parameter.secret}
404
+ if string_default:
405
+ prompt_kwargs["default"] = string_default
406
+ resolved = Prompt.ask(f" {parameter.prompt}", **prompt_kwargs).strip()
407
+ resolved = resolved or string_default
408
+ if parameter.required and not resolved:
409
+ console.print(f"[red]✗[/red] Paramètre requis manquant: [bold]{parameter.name}[/bold].")
410
+ raise typer.Exit(1)
411
+ _assert_allowed_parameter_value(parameter, resolved)
412
+ return resolved
413
+
414
+
415
+ def _assert_allowed_parameter_value(parameter: ParameterSpec, value: str) -> None:
416
+ if not parameter.choices:
417
+ return
418
+
419
+ values = _split_csv_values(value) if parameter.csv_choices else [value.strip()]
420
+ unknown = [item for item in values if item not in parameter.choices]
421
+ if not unknown:
422
+ return
423
+
424
+ allowed = ", ".join(parameter.choices)
425
+ received = ", ".join(unknown)
426
+ console.print(
427
+ f"[red]✗[/red] Valeur invalide pour [bold]{parameter.name}[/bold]: {received}. "
428
+ f"Valeurs: {allowed}."
429
+ )
430
+ raise typer.Exit(1)
431
+
432
+
433
+ def _split_csv_values(value: str) -> list[str]:
434
+ return [item.strip() for item in value.split(",") if item.strip()]
323
435
 
324
436
 
325
437
  def _default_string_value(parameter: ParameterSpec, project_dir: Path) -> str:
@@ -405,16 +517,24 @@ def _list_generated_files(
405
517
  cfg = project_dir / adapter.config_path
406
518
  files.append((cfg, "remplacé ⚠" if cfg.exists() else "créé"))
407
519
 
520
+ for merge_template in adapter.merge_config_templates:
521
+ cfg = project_dir / render(merge_template.path, {})
522
+ files.append((cfg, "mis à jour" if cfg.exists() else "créé"))
523
+
408
524
  if adapter.has_env() and adapter.env_path:
409
525
  env_file = project_dir / adapter.env_path
410
526
  files.append((env_file, "mis à jour" if env_file.exists() else "créé"))
411
527
  gitignore = project_dir / ".gitignore"
412
528
  files.append((gitignore, "mis à jour" if gitignore.exists() else "créé"))
413
529
 
414
- if adapter.has_secret_mappings():
530
+ if adapter.has_secret_mappings() or adapter.has_secret_config():
415
531
  secrets_file = project_dir / "config" / "secrets.yaml"
416
532
  files.append((secrets_file, "mis à jour" if secrets_file.exists() else "créé"))
417
533
 
534
+ if adapter.gitignore_entries:
535
+ gitignore = project_dir / ".gitignore"
536
+ files.append((gitignore, "mis à jour" if gitignore.exists() else "créé"))
537
+
418
538
  template_vars = _file_template_vars(project_dir, paths, adapter, params={})
419
539
  for file_template in adapter.file_templates:
420
540
  path = project_dir / render(file_template.path, template_vars)
@@ -465,21 +585,38 @@ def _generate(
465
585
  cfg_path.write_text(render(adapter.config_template, params), encoding="utf-8")
466
586
  console.print(f"[green]✓[/green] {cfg_path.relative_to(project_dir)}")
467
587
 
588
+ for merge_template in adapter.merge_config_templates:
589
+ cfg_path = project_dir / render(merge_template.path, params)
590
+ _merge_yaml_file(cfg_path, render(merge_template.template, params))
591
+ console.print(f"[green]✓[/green] {cfg_path.relative_to(project_dir)}")
592
+
468
593
  if adapter.has_env() and adapter.env_path:
469
594
  env_path = project_dir / adapter.env_path
470
595
  _merge_env_file(env_path, _parse_env_template(render(adapter.env_template, params)))
471
- _ensure_env_is_ignored(project_dir)
596
+ _ensure_gitignore_entries(project_dir, (".env",))
472
597
  console.print(f"[green]✓[/green] {env_path.relative_to(project_dir)}")
473
598
 
474
- if adapter.has_secret_mappings():
599
+ if adapter.has_secret_mappings() or adapter.has_secret_config():
475
600
  secrets_path = project_dir / "config" / "secrets.yaml"
476
- _merge_secrets_file(secrets_path, adapter.secret_mappings)
601
+ _merge_secrets_file(
602
+ secrets_path,
603
+ adapter.secret_mappings,
604
+ resolver=adapter.secret_resolver,
605
+ config_template=adapter.secret_config_template,
606
+ params=params,
607
+ )
477
608
  console.print(f"[green]✓[/green] {secrets_path.relative_to(project_dir)}")
478
609
 
610
+ if adapter.gitignore_entries:
611
+ _ensure_gitignore_entries(project_dir, adapter.gitignore_entries)
612
+ console.print("[green]✓[/green] .gitignore")
613
+
479
614
  for file_template in adapter.file_templates:
480
615
  generated_path = project_dir / render(file_template.path, params)
481
616
  generated_path.parent.mkdir(parents=True, exist_ok=True)
482
617
  generated_path.write_text(render(file_template.template, params), encoding="utf-8")
618
+ if generated_path.name == "arclith-run":
619
+ generated_path.chmod(0o755)
483
620
  console.print(f"[green]✓[/green] {generated_path.relative_to(project_dir)}")
484
621
 
485
622
  import_vars = _import_vars(paths)
@@ -549,9 +686,30 @@ def _file_template_vars(
549
686
  "package_path": package_path,
550
687
  "langgraph_entrypoint": langgraph_entrypoint,
551
688
  "graph_name": graph_name,
689
+ "secret_template_yaml": _secret_template_yaml(str(params.get("field_path") or "")),
690
+ "secret_chain_yaml": _secret_chain_yaml(str(params.get("resolvers") or "")),
552
691
  }
553
692
 
554
693
 
694
+ def _secret_template_yaml(field_path: str) -> str:
695
+ keys = [key for key in field_path.split(".") if key]
696
+ if not keys:
697
+ return "# Ajouter les secrets locaux ici."
698
+
699
+ data: dict[str, Any] = {}
700
+ current = data
701
+ for key in keys[:-1]:
702
+ current[key] = {}
703
+ current = current[key]
704
+ current[keys[-1]] = "replace-me"
705
+ return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).rstrip("\n")
706
+
707
+
708
+ def _secret_chain_yaml(resolvers: str) -> str:
709
+ values = _split_csv_values(resolvers or "env,vault,yaml")
710
+ return "".join(f" - {value}\n" for value in values).rstrip("\n")
711
+
712
+
555
713
  def _update_active_capability(project_dir: Path, capability: CapabilitySpec, adapter: AdapterSpec) -> None:
556
714
  if capability.activation_config_key is None:
557
715
  return
@@ -639,26 +797,71 @@ def _merge_env_file(env_path: Path, updates: dict[str, str]) -> None:
639
797
  env_path.write_text("\n".join(merged_lines).rstrip("\n") + "\n", encoding="utf-8")
640
798
 
641
799
 
642
- def _merge_secrets_file(secrets_path: Path, mappings: tuple[SecretMappingSpec, ...]) -> None:
800
+ def _merge_yaml_file(path: Path, rendered_yaml: str) -> None:
801
+ path.parent.mkdir(parents=True, exist_ok=True)
802
+ existing = _read_yaml_mapping(path)
803
+ update = yaml.safe_load(rendered_yaml) or {}
804
+ if not isinstance(update, dict):
805
+ console.print("[red]✗[/red] La configuration YAML générée doit être un mapping.")
806
+ raise typer.Exit(1)
807
+ merged = _deep_merge_mapping(existing, update)
808
+ path.write_text(yaml.safe_dump(merged, sort_keys=False, allow_unicode=True), encoding="utf-8")
809
+
810
+
811
+ def _merge_secrets_file(
812
+ secrets_path: Path,
813
+ mappings: tuple[SecretMappingSpec, ...],
814
+ *,
815
+ resolver: str | None = None,
816
+ config_template: str = "",
817
+ params: dict[str, Any] | None = None,
818
+ ) -> None:
643
819
  secrets_path.parent.mkdir(parents=True, exist_ok=True)
644
820
  data = _read_yaml_mapping(secrets_path)
645
- resolver = data.get("resolver")
646
- if not isinstance(resolver, str) or not resolver.strip():
821
+ existing_resolver = data.get("resolver")
822
+ if resolver is not None:
823
+ data["resolver"] = resolver
824
+ elif not isinstance(existing_resolver, str) or not existing_resolver.strip():
647
825
  data["resolver"] = "env"
648
826
 
827
+ render_params = params or {}
828
+ if config_template:
829
+ rendered_config = render(config_template, render_params)
830
+ config_data = yaml.safe_load(rendered_config) or {}
831
+ if not isinstance(config_data, dict):
832
+ console.print("[red]✗[/red] La configuration de secrets générée doit être un mapping YAML.")
833
+ raise typer.Exit(1)
834
+ data = _deep_merge_mapping(data, config_data)
835
+
649
836
  existing_mappings = data.get("mappings")
650
837
  if not isinstance(existing_mappings, dict):
651
838
  existing_mappings = {}
652
839
 
653
840
  merged_mappings = dict(existing_mappings)
654
841
  for mapping in mappings:
655
- merged_mappings[mapping.field_path] = mapping.secret_key
842
+ field_path = render(mapping.field_path, render_params).strip()
843
+ secret_key = render(mapping.secret_key, render_params).strip()
844
+ if not field_path:
845
+ console.print("[red]✗[/red] Un mapping de secret doit cibler un champ non vide.")
846
+ raise typer.Exit(1)
847
+ merged_mappings[field_path] = secret_key
656
848
  data["mappings"] = merged_mappings
657
849
 
658
850
  rendered = yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
659
851
  secrets_path.write_text(rendered, encoding="utf-8")
660
852
 
661
853
 
854
+ def _deep_merge_mapping(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
855
+ result = dict(base)
856
+ for key, value in override.items():
857
+ current = result.get(key)
858
+ if isinstance(current, dict) and isinstance(value, dict):
859
+ result[key] = _deep_merge_mapping(current, value)
860
+ else:
861
+ result[key] = value
862
+ return result
863
+
864
+
662
865
  def _read_yaml_mapping(path: Path) -> dict[str, Any]:
663
866
  if not path.exists():
664
867
  return {}
@@ -669,15 +872,17 @@ def _read_yaml_mapping(path: Path) -> dict[str, Any]:
669
872
  return {}
670
873
 
671
874
 
672
- def _ensure_env_is_ignored(project_dir: Path) -> None:
875
+ def _ensure_gitignore_entries(project_dir: Path, entries: tuple[str, ...]) -> None:
673
876
  gitignore = project_dir / ".gitignore"
674
877
  if gitignore.exists():
675
878
  lines = gitignore.read_text(encoding="utf-8").splitlines()
676
879
  else:
677
880
  lines = []
678
- if ".env" in {line.strip() for line in lines}:
881
+ existing = {line.strip() for line in lines}
882
+ missing = [entry for entry in entries if entry not in existing]
883
+ if not missing:
679
884
  return
680
885
  if lines and lines[-1].strip():
681
886
  lines.append("")
682
- lines.append(".env")
887
+ lines.extend(missing)
683
888
  gitignore.write_text("\n".join(lines).rstrip("\n") + "\n", encoding="utf-8")