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.
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/PKG-INFO +3 -3
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/README.md +76 -22
- arclith_cli-0.13.0/arclith_cli/__init__.py +1 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/add_adapter.py +219 -14
- arclith_cli-0.13.0/arclith_cli/capabilities.py +1516 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/core_scaffold.py +24 -24
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/init_project.py +41 -3
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/main.py +42 -11
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/project_paths.py +2 -2
- arclith_cli-0.13.0/arclith_cli/runtime_templates.py +173 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/pyproject.toml +2 -2
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/e2e_manual.sh +1 -1
- arclith_cli-0.13.0/tests/test_add_adapter.py +1905 -0
- arclith_cli-0.13.0/tests/test_capabilities.py +660 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/test_core_scaffold.py +53 -15
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/test_e2e_scaffold.py +41 -8
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/uv.lock +5 -3
- arclith_cli-0.11.0/arclith_cli/__init__.py +0 -1
- arclith_cli-0.11.0/arclith_cli/capabilities.py +0 -681
- arclith_cli-0.11.0/tests/test_add_adapter.py +0 -490
- arclith_cli-0.11.0/tests/test_capabilities.py +0 -205
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/.gitignore +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/LICENSE +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/Makefile +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/adapter_templates.py +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/entity_scanner.py +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/export_config.py +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/rename.py +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/scaffold.py +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/arclith_cli/updater.py +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/__init__.py +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/test_project_paths.py +0 -0
- {arclith_cli-0.11.0 → arclith_cli-0.13.0}/tests/test_rename.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: arclith-cli
|
|
3
|
-
Version: 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.
|
|
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
|
|
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
|
|
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-
|
|
102
|
+
### `add-intent-interpreter` — Ajouter un interpréteur d'intention
|
|
100
103
|
|
|
101
|
-
Crée uniquement le fichier minimal d'un
|
|
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-
|
|
106
|
-
arclith-cli add-
|
|
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/
|
|
116
|
+
src/<package>/application/intent_interpreters/ingredient_intent.py
|
|
113
117
|
```
|
|
114
118
|
|
|
115
|
-
|
|
116
|
-
structuré. Il ne remplace pas LangGraph : LangGraph orchestre les nœuds, tandis que
|
|
117
|
-
la traduction d'intention. Le fichier généré reste volontairement vide de
|
|
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
|
-
- `
|
|
162
|
-
|
|
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`.
|
|
202
|
-
`
|
|
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
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
646
|
-
if
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
887
|
+
lines.extend(missing)
|
|
683
888
|
gitignore.write_text("\n".join(lines).rstrip("\n") + "\n", encoding="utf-8")
|