arclith-cli 0.12.0__tar.gz → 0.14.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.12.0 → arclith_cli-0.14.0}/PKG-INFO +3 -3
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/README.md +71 -17
- arclith_cli-0.14.0/arclith_cli/__init__.py +1 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/add_adapter.py +227 -14
- arclith_cli-0.14.0/arclith_cli/capabilities.py +1749 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/init_project.py +40 -2
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/main.py +32 -1
- arclith_cli-0.14.0/arclith_cli/runtime_templates.py +173 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/pyproject.toml +2 -2
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/tests/e2e_manual.sh +1 -1
- arclith_cli-0.14.0/tests/test_add_adapter.py +2046 -0
- arclith_cli-0.14.0/tests/test_capabilities.py +767 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/tests/test_core_scaffold.py +32 -1
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/tests/test_e2e_scaffold.py +33 -2
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/uv.lock +13 -3
- arclith_cli-0.12.0/arclith_cli/__init__.py +0 -1
- arclith_cli-0.12.0/arclith_cli/capabilities.py +0 -681
- arclith_cli-0.12.0/tests/test_add_adapter.py +0 -490
- arclith_cli-0.12.0/tests/test_capabilities.py +0 -205
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/.gitignore +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/LICENSE +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/Makefile +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/adapter_templates.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/core_scaffold.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/entity_scanner.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/export_config.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/project_paths.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/rename.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/scaffold.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/arclith_cli/updater.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/tests/__init__.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.0}/tests/test_project_paths.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.14.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.14.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.17.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
|
|
|
@@ -132,42 +135,50 @@ 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
|
|
139
142
|
arclith-cli add-adapter --capability mcp --adapter fastmcp --param port=8081 --yes
|
|
140
143
|
arclith-cli add-adapter --capability llm --adapter lmstudio --param model_name=qwen/qwen3.5-9b --yes
|
|
141
|
-
arclith-cli add-adapter --capability agent --adapter langgraph --param graph_name=recipe_agent --yes
|
|
144
|
+
arclith-cli add-adapter --capability agent --adapter langgraph --param graph_name=recipe_agent --param stream_mode=updates,custom --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`
|
|
158
167
|
- `openai` → `model_name`, `base_url`, `OPENAI_API_KEY`
|
|
159
168
|
- `anthropic` → `model_name`, `ANTHROPIC_API_KEY`
|
|
160
|
-
- `langgraph` → `graph_name`
|
|
169
|
+
- `langgraph` → `graph_name`, `stream_mode`
|
|
161
170
|
- `langsmith` → `tracing`, `project`, `endpoint`, `LANGSMITH_API_KEY`
|
|
162
|
-
- `opentelemetry` → `service_name`, `endpoint`, `protocol`, `traces`, `metrics`, `instrument_fastapi`
|
|
163
|
-
- `
|
|
164
|
-
|
|
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,13 +225,24 @@ 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`.
|
|
204
|
-
`
|
|
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
|
|
208
242
|
son agent. Comme `fastapi` et `fastmcp`, LangGraph est configuré par son nom produit dans
|
|
209
|
-
`AppConfig.langgraph`, sans `adapters.agent`.
|
|
243
|
+
`AppConfig.langgraph`, sans `adapters.agent`. `stream_mode` vaut `updates` par défaut et accepte une
|
|
244
|
+
liste CSV comme `updates,custom` pour exposer les sorties de nodes et les événements
|
|
245
|
+
`get_stream_writer()`. L'adapter `observability/langsmith` génère
|
|
210
246
|
`config/adapters/outbound/langsmith.yaml`, l'ajoute à `observability.enabled`, met
|
|
211
247
|
à jour `.env` et ajoute `.env` au `.gitignore` si besoin. LangSmith Studio devient l'endroit standard
|
|
212
248
|
pour tester les agents. Une `LANGSMITH_API_KEY` déjà présente est conservée si aucune nouvelle valeur
|
|
@@ -224,6 +260,9 @@ jour `.env`, l'ajoute à `observability.enabled` et branche l'instrumentation Fa
|
|
|
224
260
|
`Arclith.fastapi()` construit l'application. Il peut être activé en même temps que LangSmith.
|
|
225
261
|
Le fichier `opentelemetry.yaml` ne porte pas de flag `enabled`: l'activation se fait uniquement dans
|
|
226
262
|
`observability.enabled`.
|
|
263
|
+
L'endpoint global est utilisé par défaut; `traces_endpoint` et `metrics_endpoint` peuvent cibler des
|
|
264
|
+
routes OTLP distinctes. Pour taguer l'environnement, définir
|
|
265
|
+
`OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=local` dans l'environnement runtime.
|
|
227
266
|
|
|
228
267
|
Parcours complet avec entité, API, LangGraph, LangSmith et LM Studio:
|
|
229
268
|
[`docs/agent-quickstart.md`](../docs/agent-quickstart.md).
|
|
@@ -310,6 +349,19 @@ config/
|
|
|
310
349
|
cache.yaml # cache: { backend, redis_url, … }
|
|
311
350
|
```
|
|
312
351
|
|
|
352
|
+
`cache/memory` génère `config/adapters/inbound/cache.yaml` avec `backend: memory` et les TTL JWKS /
|
|
353
|
+
tenant. Ce cache est strictement local au processus Python: il suffit pour le développement, les
|
|
354
|
+
tests et un worker unique. Passer à Redis dès qu'il faut partager le cache entre plusieurs workers,
|
|
355
|
+
réplicas, ou processus séparés API/MCP/agent.
|
|
356
|
+
|
|
357
|
+
`cache/redis` génère le même fichier avec `backend: redis`, mappe `cache.redis_url` vers
|
|
358
|
+
`REDIS_URL` dans `config/secrets.yaml` et écrit la valeur fournie dans `.env` local gitignoré.
|
|
359
|
+
Installer l'extra avant de lancer le service:
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
uv add "arclith[cache]"
|
|
363
|
+
```
|
|
364
|
+
|
|
313
365
|
Pour changer l'adapter actif sans passer par le wizard :
|
|
314
366
|
|
|
315
367
|
```yaml
|
|
@@ -321,4 +373,6 @@ observability:
|
|
|
321
373
|
- opentelemetry
|
|
322
374
|
```
|
|
323
375
|
|
|
324
|
-
Pour MariaDB, ne committez pas le mot de passe
|
|
376
|
+
Pour MariaDB, ne committez pas le mot de passe ni l'URL complète si elle contient des identifiants.
|
|
377
|
+
La CLI mappe `adapters.mariadb.password` vers `MARIADB_PASSWORD` et `adapters.mariadb.url` vers
|
|
378
|
+
`MARIADB_URL` dans `config/secrets.yaml`; remplacer le resolver `env` par Vault selon l'environnement.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.14.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,38 @@ def _file_template_vars(
|
|
|
549
686
|
"package_path": package_path,
|
|
550
687
|
"langgraph_entrypoint": langgraph_entrypoint,
|
|
551
688
|
"graph_name": graph_name,
|
|
689
|
+
"stream_mode_yaml": _langgraph_stream_mode_yaml(str(params.get("stream_mode") or "updates")),
|
|
690
|
+
"secret_template_yaml": _secret_template_yaml(str(params.get("field_path") or "")),
|
|
691
|
+
"secret_chain_yaml": _secret_chain_yaml(str(params.get("resolvers") or "")),
|
|
552
692
|
}
|
|
553
693
|
|
|
554
694
|
|
|
695
|
+
def _langgraph_stream_mode_yaml(stream_mode: str) -> str:
|
|
696
|
+
values = _split_csv_values(stream_mode)
|
|
697
|
+
if len(values) == 1:
|
|
698
|
+
return f'"{values[0]}"'
|
|
699
|
+
return yaml.safe_dump(values, default_flow_style=True, sort_keys=False).strip()
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _secret_template_yaml(field_path: str) -> str:
|
|
703
|
+
keys = [key for key in field_path.split(".") if key]
|
|
704
|
+
if not keys:
|
|
705
|
+
return "# Ajouter les secrets locaux ici."
|
|
706
|
+
|
|
707
|
+
data: dict[str, Any] = {}
|
|
708
|
+
current = data
|
|
709
|
+
for key in keys[:-1]:
|
|
710
|
+
current[key] = {}
|
|
711
|
+
current = current[key]
|
|
712
|
+
current[keys[-1]] = "replace-me"
|
|
713
|
+
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).rstrip("\n")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _secret_chain_yaml(resolvers: str) -> str:
|
|
717
|
+
values = _split_csv_values(resolvers or "env,vault,yaml")
|
|
718
|
+
return "".join(f" - {value}\n" for value in values).rstrip("\n")
|
|
719
|
+
|
|
720
|
+
|
|
555
721
|
def _update_active_capability(project_dir: Path, capability: CapabilitySpec, adapter: AdapterSpec) -> None:
|
|
556
722
|
if capability.activation_config_key is None:
|
|
557
723
|
return
|
|
@@ -639,26 +805,71 @@ def _merge_env_file(env_path: Path, updates: dict[str, str]) -> None:
|
|
|
639
805
|
env_path.write_text("\n".join(merged_lines).rstrip("\n") + "\n", encoding="utf-8")
|
|
640
806
|
|
|
641
807
|
|
|
642
|
-
def
|
|
808
|
+
def _merge_yaml_file(path: Path, rendered_yaml: str) -> None:
|
|
809
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
810
|
+
existing = _read_yaml_mapping(path)
|
|
811
|
+
update = yaml.safe_load(rendered_yaml) or {}
|
|
812
|
+
if not isinstance(update, dict):
|
|
813
|
+
console.print("[red]✗[/red] La configuration YAML générée doit être un mapping.")
|
|
814
|
+
raise typer.Exit(1)
|
|
815
|
+
merged = _deep_merge_mapping(existing, update)
|
|
816
|
+
path.write_text(yaml.safe_dump(merged, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _merge_secrets_file(
|
|
820
|
+
secrets_path: Path,
|
|
821
|
+
mappings: tuple[SecretMappingSpec, ...],
|
|
822
|
+
*,
|
|
823
|
+
resolver: str | None = None,
|
|
824
|
+
config_template: str = "",
|
|
825
|
+
params: dict[str, Any] | None = None,
|
|
826
|
+
) -> None:
|
|
643
827
|
secrets_path.parent.mkdir(parents=True, exist_ok=True)
|
|
644
828
|
data = _read_yaml_mapping(secrets_path)
|
|
645
|
-
|
|
646
|
-
if
|
|
829
|
+
existing_resolver = data.get("resolver")
|
|
830
|
+
if resolver is not None:
|
|
831
|
+
data["resolver"] = resolver
|
|
832
|
+
elif not isinstance(existing_resolver, str) or not existing_resolver.strip():
|
|
647
833
|
data["resolver"] = "env"
|
|
648
834
|
|
|
835
|
+
render_params = params or {}
|
|
836
|
+
if config_template:
|
|
837
|
+
rendered_config = render(config_template, render_params)
|
|
838
|
+
config_data = yaml.safe_load(rendered_config) or {}
|
|
839
|
+
if not isinstance(config_data, dict):
|
|
840
|
+
console.print("[red]✗[/red] La configuration de secrets générée doit être un mapping YAML.")
|
|
841
|
+
raise typer.Exit(1)
|
|
842
|
+
data = _deep_merge_mapping(data, config_data)
|
|
843
|
+
|
|
649
844
|
existing_mappings = data.get("mappings")
|
|
650
845
|
if not isinstance(existing_mappings, dict):
|
|
651
846
|
existing_mappings = {}
|
|
652
847
|
|
|
653
848
|
merged_mappings = dict(existing_mappings)
|
|
654
849
|
for mapping in mappings:
|
|
655
|
-
|
|
850
|
+
field_path = render(mapping.field_path, render_params).strip()
|
|
851
|
+
secret_key = render(mapping.secret_key, render_params).strip()
|
|
852
|
+
if not field_path:
|
|
853
|
+
console.print("[red]✗[/red] Un mapping de secret doit cibler un champ non vide.")
|
|
854
|
+
raise typer.Exit(1)
|
|
855
|
+
merged_mappings[field_path] = secret_key
|
|
656
856
|
data["mappings"] = merged_mappings
|
|
657
857
|
|
|
658
858
|
rendered = yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
|
|
659
859
|
secrets_path.write_text(rendered, encoding="utf-8")
|
|
660
860
|
|
|
661
861
|
|
|
862
|
+
def _deep_merge_mapping(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
863
|
+
result = dict(base)
|
|
864
|
+
for key, value in override.items():
|
|
865
|
+
current = result.get(key)
|
|
866
|
+
if isinstance(current, dict) and isinstance(value, dict):
|
|
867
|
+
result[key] = _deep_merge_mapping(current, value)
|
|
868
|
+
else:
|
|
869
|
+
result[key] = value
|
|
870
|
+
return result
|
|
871
|
+
|
|
872
|
+
|
|
662
873
|
def _read_yaml_mapping(path: Path) -> dict[str, Any]:
|
|
663
874
|
if not path.exists():
|
|
664
875
|
return {}
|
|
@@ -669,15 +880,17 @@ def _read_yaml_mapping(path: Path) -> dict[str, Any]:
|
|
|
669
880
|
return {}
|
|
670
881
|
|
|
671
882
|
|
|
672
|
-
def
|
|
883
|
+
def _ensure_gitignore_entries(project_dir: Path, entries: tuple[str, ...]) -> None:
|
|
673
884
|
gitignore = project_dir / ".gitignore"
|
|
674
885
|
if gitignore.exists():
|
|
675
886
|
lines = gitignore.read_text(encoding="utf-8").splitlines()
|
|
676
887
|
else:
|
|
677
888
|
lines = []
|
|
678
|
-
|
|
889
|
+
existing = {line.strip() for line in lines}
|
|
890
|
+
missing = [entry for entry in entries if entry not in existing]
|
|
891
|
+
if not missing:
|
|
679
892
|
return
|
|
680
893
|
if lines and lines[-1].strip():
|
|
681
894
|
lines.append("")
|
|
682
|
-
lines.
|
|
895
|
+
lines.extend(missing)
|
|
683
896
|
gitignore.write_text("\n".join(lines).rstrip("\n") + "\n", encoding="utf-8")
|