arclith-cli 0.11.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.11.0 → arclith_cli-0.13.0}/PKG-INFO +3 -3
  2. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/README.md +76 -22
  3. arclith_cli-0.13.0/arclith_cli/__init__.py +1 -0
  4. {arclith_cli-0.11.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.11.0 → arclith_cli-0.13.0}/arclith_cli/core_scaffold.py +24 -24
  7. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/init_project.py +41 -3
  8. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/main.py +42 -11
  9. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/project_paths.py +2 -2
  10. arclith_cli-0.13.0/arclith_cli/runtime_templates.py +173 -0
  11. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/pyproject.toml +2 -2
  12. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/e2e_manual.sh +1 -1
  13. arclith_cli-0.13.0/tests/test_add_adapter.py +1905 -0
  14. arclith_cli-0.13.0/tests/test_capabilities.py +660 -0
  15. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/test_core_scaffold.py +53 -15
  16. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/test_e2e_scaffold.py +41 -8
  17. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/uv.lock +5 -3
  18. arclith_cli-0.11.0/arclith_cli/__init__.py +0 -1
  19. arclith_cli-0.11.0/arclith_cli/capabilities.py +0 -681
  20. arclith_cli-0.11.0/tests/test_add_adapter.py +0 -490
  21. arclith_cli-0.11.0/tests/test_capabilities.py +0 -205
  22. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/.gitignore +0 -0
  23. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/LICENSE +0 -0
  24. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/Makefile +0 -0
  25. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/adapter_templates.py +0 -0
  26. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/entity_scanner.py +0 -0
  27. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/export_config.py +0 -0
  28. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/rename.py +0 -0
  29. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/scaffold.py +0 -0
  30. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/updater.py +0 -0
  31. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/__init__.py +0 -0
  32. {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/test_project_paths.py +0 -0
  33. {arclith_cli-0.11.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.11.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.14.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
 
@@ -96,25 +99,27 @@ Comme `add-entity`, cette commande ne câble pas FastAPI, FastMCP, LangGraph, un
96
99
 
97
100
  ---
98
101
 
99
- ### `add-planner` — Ajouter un planner applicatif
102
+ ### `add-intent-interpreter` — Ajouter un interpréteur d'intention
100
103
 
101
- Crée uniquement le fichier minimal d'un planner dans `src/<package>/application/planners/`.
104
+ Crée uniquement le fichier minimal d'un interpréteur d'intention dans
105
+ `src/<package>/application/intent_interpreters/`.
102
106
 
103
107
  ```bash
104
108
  cd my-recipe-service
105
- arclith-cli add-planner IngredientIntent
106
- arclith-cli add-planner command-router
109
+ arclith-cli add-intent-interpreter IngredientIntent
110
+ arclith-cli add-intent-interpreter command-router
107
111
  ```
108
112
 
109
113
  Fichier généré :
110
114
 
111
115
  ```text
112
- src/<package>/application/planners/ingredient_intent.py
116
+ src/<package>/application/intent_interpreters/ingredient_intent.py
113
117
  ```
114
118
 
115
- Le planner est le composant applicatif qui transforme une demande naturelle en commande ou DTO
116
- structuré. Il ne remplace pas LangGraph : LangGraph orchestre les nœuds, tandis que le planner porte
117
- la traduction d'intention. Le fichier généré reste volontairement vide de logique métier.
119
+ L'interpréteur d'intention est le composant applicatif qui transforme une demande naturelle en
120
+ commande ou DTO structuré. Il ne remplace pas LangGraph : LangGraph orchestre les nœuds, tandis que
121
+ l'interpréteur porte la traduction d'intention. Le fichier généré reste volontairement vide de
122
+ logique métier.
118
123
 
119
124
  ---
120
125
 
@@ -130,7 +135,7 @@ arclith-cli add-adapter
130
135
  Mode direct, utile pour CI, scripts de migration ou commandes reproductibles :
131
136
 
132
137
  ```bash
133
- 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
134
139
  arclith-cli add-adapter --adapter duckdb --all-entities --path data/ --no-activate --yes
135
140
  arclith-cli add-adapter --adapter mariadb --entity Recipe --param database=my_recipe_service --param user=app --yes
136
141
  arclith-cli add-adapter --capability api --adapter fastapi --param port=8080 --yes
@@ -139,17 +144,23 @@ arclith-cli add-adapter --capability llm --adapter lmstudio --param model_name=q
139
144
  arclith-cli add-adapter --capability agent --adapter langgraph --param graph_name=recipe_agent --yes
140
145
  arclith-cli add-adapter --capability observability --adapter langsmith
141
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
142
150
  arclith-cli add-adapter --capability repository --adapter memory --entity Recipe --yes
143
151
  ```
144
152
 
145
153
  **Étapes du wizard :**
146
154
 
147
- 1. **Type d'adapter** — selon la capacité : `memory` · `mongodb` · `duckdb` · `mariadb` · `fastapi` · `fastmcp` · `lmstudio` · `openai` · `anthropic` · `langgraph` · `langsmith` · `opentelemetry`
148
- 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
149
157
  3. **Paramètres** — questions spécifiques à l'adapter :
150
- - `mongodb` → `db_name`, `multitenant`
158
+ - `mongodb` → `db_name`, `collection_name`, `multitenant`
151
159
  - `duckdb` → `path`
152
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`
153
164
  - `fastapi` → `host`, `port`, `reload`
154
165
  - `fastmcp` → `host`, `port`
155
166
  - `lmstudio` → `model_name`, `base_url`, `api_key`
@@ -157,15 +168,17 @@ arclith-cli add-adapter --capability repository --adapter memory --entity Recipe
157
168
  - `anthropic` → `model_name`, `ANTHROPIC_API_KEY`
158
169
  - `langgraph` → `graph_name`
159
170
  - `langsmith` → `tracing`, `project`, `endpoint`, `LANGSMITH_API_KEY`
160
- - `opentelemetry` → `service_name`, `endpoint`, `protocol`, `traces`, `metrics`, `instrument_fastapi`
161
- - `memory` → aucun paramètre
162
- 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
163
176
  5. **Récapitulatif** — liste des fichiers créés ou remplacés avant confirmation
164
177
 
165
178
  | Option | Défaut | Description |
166
179
  |--------|--------|-------------|
167
- | `--capability` | `repository` | Capacité cible du catalogue standardisé (`repository`, `api`, `mcp`, `llm`, `agent`, `observability`) |
168
- | `--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` |
169
182
  | `--entity` / `-e` | auto si une seule entité | Entité cible, liste séparée par virgule acceptée |
170
183
  | `--all-entities` | `false` | Génère l'adapter pour toutes les entités détectées |
171
184
  | `--activate/--no-activate` | `--activate` | Met à jour `config/adapters/adapters.yaml` quand la capacité expose une clé d'activation |
@@ -187,6 +200,20 @@ src/<package>/infrastructure/containers/<entity>_container.py # RepositoryRegis
187
200
 
188
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.
189
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
+
190
217
  **LangGraph / LangSmith :**
191
218
 
192
219
  ```bash
@@ -198,8 +225,17 @@ uv run langgraph dev --no-browser --allow-blocking --port 2024
198
225
  ```
199
226
 
200
227
  L'adapter `llm/lmstudio` génère `config/adapters/outbound/lm.yaml`, chargé dans
201
- `AppConfig.adapters.lm`. Les adapters `llm/openai` et `llm/anthropic` génèrent aussi un mapping
202
- `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.
203
239
 
204
240
  L'adapter `agent/langgraph` génère `langgraph.json`, `config/adapters/inbound/langgraph.yaml` et
205
241
  `src/<package>/adapters/inbound/langgraph/agent.py`. Le projet ne modifie ensuite que ce fichier pour
@@ -222,6 +258,9 @@ jour `.env`, l'ajoute à `observability.enabled` et branche l'instrumentation Fa
222
258
  `Arclith.fastapi()` construit l'application. Il peut être activé en même temps que LangSmith.
223
259
  Le fichier `opentelemetry.yaml` ne porte pas de flag `enabled`: l'activation se fait uniquement dans
224
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.
225
264
 
226
265
  Parcours complet avec entité, API, LangGraph, LangSmith et LM Studio:
227
266
  [`docs/agent-quickstart.md`](../docs/agent-quickstart.md).
@@ -308,6 +347,19 @@ config/
308
347
  cache.yaml # cache: { backend, redis_url, … }
309
348
  ```
310
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
+
311
363
  Pour changer l'adapter actif sans passer par le wizard :
312
364
 
313
365
  ```yaml
@@ -319,4 +371,6 @@ observability:
319
371
  - opentelemetry
320
372
  ```
321
373
 
322
- 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")