arclith-cli 0.12.0__tar.gz → 0.13.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/PKG-INFO +3 -3
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/README.md +66 -14
- arclith_cli-0.13.0/arclith_cli/__init__.py +1 -0
- {arclith_cli-0.12.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.12.0 → arclith_cli-0.13.0}/arclith_cli/init_project.py +40 -2
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/main.py +32 -1
- arclith_cli-0.13.0/arclith_cli/runtime_templates.py +173 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/pyproject.toml +2 -2
- {arclith_cli-0.12.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.12.0 → arclith_cli-0.13.0}/tests/test_core_scaffold.py +32 -1
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/tests/test_e2e_scaffold.py +33 -2
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/uv.lock +5 -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.13.0}/.gitignore +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/LICENSE +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/Makefile +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/adapter_templates.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/core_scaffold.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/entity_scanner.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/export_config.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/project_paths.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/rename.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/scaffold.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/arclith_cli/updater.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/tests/__init__.py +0 -0
- {arclith_cli-0.12.0 → arclith_cli-0.13.0}/tests/test_project_paths.py +0 -0
- {arclith_cli-0.12.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
|
|
|
@@ -132,7 +135,7 @@ arclith-cli add-adapter
|
|
|
132
135
|
Mode direct, utile pour CI, scripts de migration ou commandes reproductibles :
|
|
133
136
|
|
|
134
137
|
```bash
|
|
135
|
-
arclith-cli add-adapter --adapter mongodb --entity Recipe --db-name my_recipe_service --yes
|
|
138
|
+
arclith-cli add-adapter --adapter mongodb --entity Recipe --db-name my_recipe_service --param collection_name=recipes --yes
|
|
136
139
|
arclith-cli add-adapter --adapter duckdb --all-entities --path data/ --no-activate --yes
|
|
137
140
|
arclith-cli add-adapter --adapter mariadb --entity Recipe --param database=my_recipe_service --param user=app --yes
|
|
138
141
|
arclith-cli add-adapter --capability api --adapter fastapi --param port=8080 --yes
|
|
@@ -141,17 +144,23 @@ arclith-cli add-adapter --capability llm --adapter lmstudio --param model_name=q
|
|
|
141
144
|
arclith-cli add-adapter --capability agent --adapter langgraph --param graph_name=recipe_agent --yes
|
|
142
145
|
arclith-cli add-adapter --capability observability --adapter langsmith
|
|
143
146
|
arclith-cli add-adapter --capability observability --adapter opentelemetry --param service_name=my_recipe_service --yes
|
|
147
|
+
arclith-cli add-adapter --capability runtime --adapter docker-image --yes
|
|
148
|
+
arclith-cli add-adapter --capability cache --adapter memory --yes
|
|
149
|
+
arclith-cli add-adapter --capability cache --adapter redis --param redis_url=redis://redis:6379 --yes
|
|
144
150
|
arclith-cli add-adapter --capability repository --adapter memory --entity Recipe --yes
|
|
145
151
|
```
|
|
146
152
|
|
|
147
153
|
**Étapes du wizard :**
|
|
148
154
|
|
|
149
|
-
1. **Type d'adapter** — selon la capacité : `memory` · `mongodb` · `duckdb` · `mariadb` · `fastapi` · `fastmcp` · `lmstudio` · `openai` · `anthropic` · `langgraph` · `langsmith` · `opentelemetry`
|
|
150
|
-
2. **Entité(s) cible(s)** — détectées automatiquement pour les adapters entity-scoped ; ignorées pour les transports globaux, `llm/*`, `agent/langgraph` et les adapters d'observability
|
|
155
|
+
1. **Type d'adapter** — selon la capacité : `memory` · `mongodb` · `duckdb` · `mariadb` · `fastapi` · `fastmcp` · `rabbitmq` · `docker-image` · `lmstudio` · `openai` · `anthropic` · `langgraph` · `langsmith` · `opentelemetry`
|
|
156
|
+
2. **Entité(s) cible(s)** — détectées automatiquement pour les adapters entity-scoped ; ignorées pour les transports globaux, `cache/*`, `llm/*`, `agent/langgraph`, `runtime/docker-image` et les adapters d'observability
|
|
151
157
|
3. **Paramètres** — questions spécifiques à l'adapter :
|
|
152
|
-
- `mongodb` → `db_name`, `multitenant`
|
|
158
|
+
- `mongodb` → `db_name`, `collection_name`, `multitenant`
|
|
153
159
|
- `duckdb` → `path`
|
|
154
160
|
- `mariadb` → `host`, `port`, `database`, `user`, `driver`, `table_prefix`
|
|
161
|
+
(`url` et `password` sont mappés via `config/secrets.yaml`)
|
|
162
|
+
- `cache/memory` → `jwks_ttl`, `tenant_uri_ttl`
|
|
163
|
+
- `cache/redis` → `redis_url`, `jwks_ttl`, `tenant_uri_ttl`
|
|
155
164
|
- `fastapi` → `host`, `port`, `reload`
|
|
156
165
|
- `fastmcp` → `host`, `port`
|
|
157
166
|
- `lmstudio` → `model_name`, `base_url`, `api_key`
|
|
@@ -159,15 +168,17 @@ arclith-cli add-adapter --capability repository --adapter memory --entity Recipe
|
|
|
159
168
|
- `anthropic` → `model_name`, `ANTHROPIC_API_KEY`
|
|
160
169
|
- `langgraph` → `graph_name`
|
|
161
170
|
- `langsmith` → `tracing`, `project`, `endpoint`, `LANGSMITH_API_KEY`
|
|
162
|
-
- `opentelemetry` → `service_name`, `endpoint`, `protocol`, `traces`, `metrics`, `instrument_fastapi`
|
|
163
|
-
- `
|
|
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,8 +225,17 @@ uv run langgraph dev --no-browser --allow-blocking --port 2024
|
|
|
200
225
|
```
|
|
201
226
|
|
|
202
227
|
L'adapter `llm/lmstudio` génère `config/adapters/outbound/lm.yaml`, chargé dans
|
|
203
|
-
`AppConfig.adapters.lm`.
|
|
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
|
|
@@ -224,6 +258,9 @@ jour `.env`, l'ajoute à `observability.enabled` et branche l'instrumentation Fa
|
|
|
224
258
|
`Arclith.fastapi()` construit l'application. Il peut être activé en même temps que LangSmith.
|
|
225
259
|
Le fichier `opentelemetry.yaml` ne porte pas de flag `enabled`: l'activation se fait uniquement dans
|
|
226
260
|
`observability.enabled`.
|
|
261
|
+
L'endpoint global est utilisé par défaut; `traces_endpoint` et `metrics_endpoint` peuvent cibler des
|
|
262
|
+
routes OTLP distinctes. Pour taguer l'environnement, définir
|
|
263
|
+
`OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=local` dans l'environnement runtime.
|
|
227
264
|
|
|
228
265
|
Parcours complet avec entité, API, LangGraph, LangSmith et LM Studio:
|
|
229
266
|
[`docs/agent-quickstart.md`](../docs/agent-quickstart.md).
|
|
@@ -310,6 +347,19 @@ config/
|
|
|
310
347
|
cache.yaml # cache: { backend, redis_url, … }
|
|
311
348
|
```
|
|
312
349
|
|
|
350
|
+
`cache/memory` génère `config/adapters/inbound/cache.yaml` avec `backend: memory` et les TTL JWKS /
|
|
351
|
+
tenant. Ce cache est strictement local au processus Python: il suffit pour le développement, les
|
|
352
|
+
tests et un worker unique. Passer à Redis dès qu'il faut partager le cache entre plusieurs workers,
|
|
353
|
+
réplicas, ou processus séparés API/MCP/agent.
|
|
354
|
+
|
|
355
|
+
`cache/redis` génère le même fichier avec `backend: redis`, mappe `cache.redis_url` vers
|
|
356
|
+
`REDIS_URL` dans `config/secrets.yaml` et écrit la valeur fournie dans `.env` local gitignoré.
|
|
357
|
+
Installer l'extra avant de lancer le service:
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
uv add "arclith[cache]"
|
|
361
|
+
```
|
|
362
|
+
|
|
313
363
|
Pour changer l'adapter actif sans passer par le wizard :
|
|
314
364
|
|
|
315
365
|
```yaml
|
|
@@ -321,4 +371,6 @@ observability:
|
|
|
321
371
|
- opentelemetry
|
|
322
372
|
```
|
|
323
373
|
|
|
324
|
-
Pour MariaDB, ne committez pas le mot de passe
|
|
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")
|