arclith-cli 0.9.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/PKG-INFO +2 -2
  2. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/README.md +65 -6
  3. arclith_cli-0.10.0/arclith_cli/__init__.py +1 -0
  4. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/arclith_cli/capabilities.py +167 -1
  5. arclith_cli-0.10.0/arclith_cli/core_scaffold.py +165 -0
  6. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/arclith_cli/main.py +43 -1
  7. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/arclith_cli/project_paths.py +23 -3
  8. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/arclith_cli/rename.py +23 -2
  9. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/pyproject.toml +2 -2
  10. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/tests/e2e_manual.sh +1 -1
  11. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/tests/test_add_adapter.py +113 -0
  12. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/tests/test_capabilities.py +64 -5
  13. arclith_cli-0.10.0/tests/test_core_scaffold.py +91 -0
  14. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/tests/test_e2e_scaffold.py +57 -1
  15. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/tests/test_project_paths.py +16 -0
  16. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/tests/test_rename.py +12 -1
  17. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/uv.lock +13 -3
  18. arclith_cli-0.9.0/arclith_cli/__init__.py +0 -1
  19. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/.gitignore +0 -0
  20. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/LICENSE +0 -0
  21. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/Makefile +0 -0
  22. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/arclith_cli/adapter_templates.py +0 -0
  23. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/arclith_cli/add_adapter.py +0 -0
  24. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/arclith_cli/entity_scanner.py +0 -0
  25. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/arclith_cli/export_config.py +0 -0
  26. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/arclith_cli/scaffold.py +0 -0
  27. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/arclith_cli/updater.py +0 -0
  28. {arclith_cli-0.9.0 → arclith_cli-0.10.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arclith-cli
3
- Version: 0.9.0
3
+ Version: 0.10.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.12.0
189
+ Requires-Dist: arclith>=0.13.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
@@ -34,6 +34,47 @@ Le projet généré utilise un layout `src/<package>/...` pour le code applicati
34
34
 
35
35
  ---
36
36
 
37
+ ### `add-entity` — Ajouter une entité métier
38
+
39
+ Crée uniquement le fichier minimal d'une entité dans `src/<package>/domain/models/`.
40
+
41
+ ```bash
42
+ cd my-recipe-service
43
+ arclith-cli add-entity ShoppingItem
44
+ ```
45
+
46
+ Fichier généré :
47
+
48
+ ```text
49
+ src/<package>/domain/models/shopping_item.py
50
+ ```
51
+
52
+ La commande ne génère aucun CRUD, aucun port repository, aucun adapter et aucun endpoint. Elle pose seulement le point d'ancrage du modèle métier ; le développeur complète ensuite les champs et invariants de l'entité.
53
+
54
+ ---
55
+
56
+ ### `add-usecase` — Ajouter un cas d'usage
57
+
58
+ Crée uniquement le fichier minimal d'un cas d'usage dans `src/<package>/application/use_cases/`.
59
+
60
+ ```bash
61
+ cd my-recipe-service
62
+ arclith-cli add-usecase PlanShoppingList
63
+ arclith-cli add-usecase find-by-name
64
+ ```
65
+
66
+ Fichier généré :
67
+
68
+ ```text
69
+ src/<package>/application/use_cases/plan_shopping_list.py
70
+ ```
71
+
72
+ Le nom peut être fourni en PascalCase, snake_case ou kebab-case. Le suffixe `UseCase` est normalisé : `PlanShoppingListUseCase` et `plan-shopping-list-use-case` génèrent tous les deux `PlanShoppingListUseCase`.
73
+
74
+ Comme `add-entity`, cette commande ne câble pas FastAPI, FastMCP, LangGraph, un repository ou un service. Les adapters se branchent ensuite explicitement avec `add-adapter`.
75
+
76
+ ---
77
+
37
78
  ### `add-adapter` — Ajouter un adapter
38
79
 
39
80
  Wizard interactif à lancer **depuis la racine du projet cible**. Scaffold le code Python et/ou les fichiers de configuration pour un nouvel adapter. Par défaut, la capacité cible est `repository`.
@@ -49,29 +90,35 @@ Mode direct, utile pour CI, scripts de migration ou commandes reproductibles :
49
90
  arclith-cli add-adapter --adapter mongodb --entity Recipe --db-name my_recipe_service --yes
50
91
  arclith-cli add-adapter --adapter duckdb --all-entities --path data/ --no-activate --yes
51
92
  arclith-cli add-adapter --adapter mariadb --entity Recipe --param database=my_recipe_service --param user=app --yes
93
+ arclith-cli add-adapter --capability api --adapter fastapi --param port=8080 --yes
94
+ arclith-cli add-adapter --capability mcp --adapter fastmcp --param port=8081 --yes
52
95
  arclith-cli add-adapter --capability agent --adapter langgraph --param graph_name=recipe_agent --yes
53
96
  arclith-cli add-adapter --capability observability --adapter langsmith
97
+ arclith-cli add-adapter --capability observability --adapter opentelemetry --param service_name=my_recipe_service --yes
54
98
  arclith-cli add-adapter --capability repository --adapter memory --entity Recipe --yes
55
99
  ```
56
100
 
57
101
  **Étapes du wizard :**
58
102
 
59
- 1. **Type d'adapter** — selon la capacité : `memory` · `mongodb` · `duckdb` · `mariadb` · `langgraph` · `langsmith`
60
- 2. **Entité(s) cible(s)** — détectées automatiquement pour les adapters entity-scoped ; ignorées pour `agent/langgraph` et `observability/langsmith`
103
+ 1. **Type d'adapter** — selon la capacité : `memory` · `mongodb` · `duckdb` · `mariadb` · `fastapi` · `fastmcp` · `langgraph` · `langsmith` · `opentelemetry`
104
+ 2. **Entité(s) cible(s)** — détectées automatiquement pour les adapters entity-scoped ; ignorées pour les transports globaux, `agent/langgraph` et les adapters d'observability
61
105
  3. **Paramètres** — questions spécifiques à l'adapter :
62
106
  - `mongodb` → `db_name`, `multitenant`
63
107
  - `duckdb` → `path`
64
108
  - `mariadb` → `host`, `port`, `database`, `user`, `driver`, `table_prefix`
109
+ - `fastapi` → `host`, `port`, `reload`
110
+ - `fastmcp` → `host`, `port`
65
111
  - `langgraph` → `graph_name`
66
112
  - `langsmith` → `tracing`, `project`, `endpoint`, `LANGSMITH_API_KEY`
113
+ - `opentelemetry` → `service_name`, `endpoint`, `protocol`, `traces`, `metrics`, `instrument_fastapi`
67
114
  - `memory` → aucun paramètre
68
- 4. **Activation** — met à jour `config/adapters/adapters.yaml` pour les capacités à sélecteur (`repository: <adapter>` ou `observability: langsmith`) ; `agent/langgraph` est exposé par `langgraph.json` et `config/adapters/inbound/langgraph.yaml`
115
+ 4. **Activation** — met à jour `config/adapters/adapters.yaml` pour les capacités à sélecteur (`repository: <adapter>` ou `observability: <adapter>`) ; `api/fastapi`, `mcp/fastmcp` et `agent/langgraph` sont exposés par leurs fichiers `config/adapters/inbound/*.yaml`
69
116
  5. **Récapitulatif** — liste des fichiers créés ou remplacés avant confirmation
70
117
 
71
118
  | Option | Défaut | Description |
72
119
  |--------|--------|-------------|
73
- | `--capability` | `repository` | Capacité cible du catalogue standardisé (`repository`, `agent`, `observability`) |
74
- | `--adapter` / `-a` | interactif | Adapter du catalogue : `memory`, `mongodb`, `duckdb`, `mariadb`, `langgraph`, `langsmith` |
120
+ | `--capability` | `repository` | Capacité cible du catalogue standardisé (`repository`, `api`, `mcp`, `agent`, `observability`) |
121
+ | `--adapter` / `-a` | interactif | Adapter du catalogue : `memory`, `mongodb`, `duckdb`, `mariadb`, `fastapi`, `fastmcp`, `langgraph`, `langsmith`, `opentelemetry` |
75
122
  | `--entity` / `-e` | auto si une seule entité | Entité cible, liste séparée par virgule acceptée |
76
123
  | `--all-entities` | `false` | Génère l'adapter pour toutes les entités détectées |
77
124
  | `--activate/--no-activate` | `--activate` | Met à jour `config/adapters/adapters.yaml` quand la capacité expose une clé d'activation |
@@ -111,6 +158,17 @@ son agent. Comme `fastapi` et `fastmcp`, LangGraph est configuré par son nom pr
111
158
  pour tester les agents. Une `LANGSMITH_API_KEY` déjà présente est conservée si aucune nouvelle valeur
112
159
  n'est fournie.
113
160
 
161
+ **OpenTelemetry :**
162
+
163
+ ```bash
164
+ uv add "arclith[opentelemetry]"
165
+ arclith-cli add-adapter --capability observability --adapter opentelemetry --param service_name=my-recipe-service --yes
166
+ ```
167
+
168
+ L'adapter `observability/opentelemetry` génère `config/adapters/outbound/opentelemetry.yaml`, met à
169
+ jour `.env`, active `observability: opentelemetry` et branche l'instrumentation FastAPI quand
170
+ `Arclith.fastapi()` construit l'application.
171
+
114
172
  Parcours complet avec entité, API, LangGraph, LangSmith et LM Studio:
115
173
  [`docs/agent-quickstart.md`](../docs/agent-quickstart.md).
116
174
 
@@ -184,6 +242,7 @@ config/
184
242
  duckdb.yaml # adapters.duckdb: { path, multitenant }
185
243
  mariadb.yaml # adapters.mariadb: { host, port, database, user, ... }
186
244
  langsmith.yaml # adapters.langsmith: { tracing, project, endpoint, ... }
245
+ opentelemetry.yaml # adapters.opentelemetry: { endpoint, protocol, traces, metrics, ... }
187
246
  inbound/
188
247
  fastapi.yaml # api: { host, port, reload }
189
248
  fastmcp.yaml # mcp: { host, port }
@@ -199,7 +258,7 @@ Pour changer l'adapter actif sans passer par le wizard :
199
258
  ```yaml
200
259
  # config/adapters/adapters.yaml
201
260
  repository: duckdb # memory | mongodb | duckdb | mariadb
202
- observability: langsmith
261
+ observability: langsmith # none | langsmith | opentelemetry
203
262
  ```
204
263
 
205
264
  Pour MariaDB, ne committez pas le mot de passe. Mappez `adapters.mariadb.password` ou `adapters.mariadb.url` via `config/secrets.yaml`, un resolver `env` ou Vault.
@@ -0,0 +1 @@
1
+ __version__ = "0.10.0"
@@ -217,6 +217,83 @@ multitenant: false
217
217
  ),
218
218
  )
219
219
 
220
+ API_CAPABILITY = CapabilitySpec(
221
+ name="api",
222
+ layer="inbound",
223
+ description="Transport HTTP REST expose via FastAPI.",
224
+ activation_config_key=None,
225
+ adapters=(
226
+ AdapterSpec(
227
+ name="fastapi",
228
+ capability="api",
229
+ layer="inbound",
230
+ description="Application FastAPI configuree par Arclith.fastapi().",
231
+ config_path="config/adapters/inbound/fastapi.yaml",
232
+ config_template="""\
233
+ host: {host}
234
+ port: {port}
235
+ reload: {reload}
236
+ """,
237
+ parameters=(
238
+ ParameterSpec(
239
+ name="host",
240
+ kind="string",
241
+ prompt="Host FastAPI",
242
+ default="0.0.0.0",
243
+ ),
244
+ ParameterSpec(
245
+ name="port",
246
+ kind="string",
247
+ prompt="Port FastAPI",
248
+ default="8000",
249
+ ),
250
+ ParameterSpec(
251
+ name="reload",
252
+ kind="boolean",
253
+ prompt="Activer le reload FastAPI",
254
+ default=True,
255
+ ),
256
+ ),
257
+ entity_scoped=False,
258
+ ),
259
+ ),
260
+ )
261
+
262
+ MCP_CAPABILITY = CapabilitySpec(
263
+ name="mcp",
264
+ layer="inbound",
265
+ description="Transport MCP expose via FastMCP.",
266
+ activation_config_key=None,
267
+ adapters=(
268
+ AdapterSpec(
269
+ name="fastmcp",
270
+ capability="mcp",
271
+ layer="inbound",
272
+ description="Serveur FastMCP configure par Arclith.fastmcp() et les runners MCP.",
273
+ config_path="config/adapters/inbound/fastmcp.yaml",
274
+ config_template="""\
275
+ host: {host}
276
+ port: {port}
277
+ """,
278
+ parameters=(
279
+ ParameterSpec(
280
+ name="host",
281
+ kind="string",
282
+ prompt="Host FastMCP",
283
+ default="127.0.0.1",
284
+ ),
285
+ ParameterSpec(
286
+ name="port",
287
+ kind="string",
288
+ prompt="Port FastMCP",
289
+ default="8001",
290
+ ),
291
+ ),
292
+ entity_scoped=False,
293
+ ),
294
+ ),
295
+ )
296
+
220
297
  AGENT_CAPABILITY = CapabilitySpec(
221
298
  name="agent",
222
299
  layer="inbound",
@@ -351,10 +428,99 @@ LANGSMITH_API_KEY={api_key}
351
428
  ),
352
429
  entity_scoped=False,
353
430
  ),
431
+ AdapterSpec(
432
+ name="opentelemetry",
433
+ capability="observability",
434
+ layer="outbound",
435
+ description="Export OTLP traces/metrics et instrumentation FastAPI.",
436
+ config_path="config/adapters/outbound/opentelemetry.yaml",
437
+ config_template="""\
438
+ enabled: {enabled}
439
+ service_name: "{service_name}"
440
+ endpoint: "{endpoint}"
441
+ protocol: "{protocol}"
442
+ headers_env: OTEL_EXPORTER_OTLP_HEADERS
443
+ traces: {traces}
444
+ metrics: {metrics}
445
+ instrument_fastapi: {instrument_fastapi}
446
+ metrics_export_interval_millis: {metrics_export_interval_millis}
447
+ """,
448
+ env_path=".env",
449
+ env_template="""\
450
+ OTEL_SERVICE_NAME={service_name}
451
+ OTEL_EXPORTER_OTLP_ENDPOINT={endpoint}
452
+ OTEL_EXPORTER_OTLP_PROTOCOL={protocol}
453
+ OTEL_EXPORTER_OTLP_HEADERS={headers}
454
+ """,
455
+ parameters=(
456
+ ParameterSpec(
457
+ name="enabled",
458
+ kind="boolean",
459
+ prompt="Activer OpenTelemetry",
460
+ default=True,
461
+ ),
462
+ ParameterSpec(
463
+ name="service_name",
464
+ kind="string",
465
+ prompt="OTEL_SERVICE_NAME",
466
+ default_from_project_name=True,
467
+ ),
468
+ ParameterSpec(
469
+ name="endpoint",
470
+ kind="string",
471
+ prompt="OTLP endpoint",
472
+ default="http://localhost:4318",
473
+ ),
474
+ ParameterSpec(
475
+ name="protocol",
476
+ kind="string",
477
+ prompt="OTLP protocol",
478
+ default="http/protobuf",
479
+ ),
480
+ ParameterSpec(
481
+ name="traces",
482
+ kind="boolean",
483
+ prompt="Exporter les traces",
484
+ default=True,
485
+ ),
486
+ ParameterSpec(
487
+ name="metrics",
488
+ kind="boolean",
489
+ prompt="Exporter les metriques",
490
+ default=False,
491
+ ),
492
+ ParameterSpec(
493
+ name="instrument_fastapi",
494
+ kind="boolean",
495
+ prompt="Instrumenter FastAPI",
496
+ default=True,
497
+ ),
498
+ ParameterSpec(
499
+ name="metrics_export_interval_millis",
500
+ kind="string",
501
+ prompt="Intervalle export metriques en ms",
502
+ default="60000",
503
+ ),
504
+ ParameterSpec(
505
+ name="headers",
506
+ kind="string",
507
+ prompt="OTEL_EXPORTER_OTLP_HEADERS",
508
+ default="",
509
+ secret=True,
510
+ ),
511
+ ),
512
+ entity_scoped=False,
513
+ ),
354
514
  ),
355
515
  )
356
516
 
357
- CAPABILITY_CATALOG = (REPOSITORY_CAPABILITY, AGENT_CAPABILITY, OBSERVABILITY_CAPABILITY)
517
+ CAPABILITY_CATALOG = (
518
+ REPOSITORY_CAPABILITY,
519
+ API_CAPABILITY,
520
+ MCP_CAPABILITY,
521
+ AGENT_CAPABILITY,
522
+ OBSERVABILITY_CAPABILITY,
523
+ )
358
524
 
359
525
 
360
526
  def get_capability(name: str) -> CapabilitySpec | None:
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from .project_paths import ProjectPaths, detect_project_paths
11
+ from .rename import EntityNames
12
+
13
+ console = Console()
14
+
15
+ _NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_\-]*$")
16
+
17
+ _ENTITY_TEMPLATE = """from arclith.domain.models.entity import Entity
18
+
19
+
20
+ class {class_name}(Entity):
21
+ pass
22
+ """
23
+
24
+ _USE_CASE_TEMPLATE = """class {class_name}UseCase:
25
+ async def execute(self) -> None:
26
+ raise NotImplementedError("Implement {class_name}UseCase.execute")
27
+ """
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class UseCaseNames:
32
+ pascal: str
33
+ snake: str
34
+
35
+ @classmethod
36
+ def from_input(cls, raw: str) -> UseCaseNames:
37
+ names = EntityNames.from_input(raw)
38
+ pascal = _strip_pascal_suffix(names.pascal)
39
+ snake = _strip_snake_suffix(names.snake)
40
+ if not pascal or not snake:
41
+ console.print(f"[red]✗[/red] Nom de cas d'usage invalide : [bold]{raw}[/bold].")
42
+ raise typer.Exit(1)
43
+ return cls(pascal=pascal, snake=snake)
44
+
45
+
46
+ def add_entity_cmd(*, project_dir: Path | None = None, entity_name: str) -> Path:
47
+ project_dir = project_dir or Path.cwd()
48
+ entity_name = entity_name.strip()
49
+ _assert_project_root(project_dir, command="add-entity")
50
+ _assert_valid_name(entity_name, label="entité")
51
+
52
+ paths = detect_project_paths(project_dir)
53
+ names = EntityNames.from_input(entity_name)
54
+ entity_file = paths.domain_models / f"{names.snake}.py"
55
+ _assert_missing(entity_file, project_dir)
56
+
57
+ _ensure_package_dirs(paths, "domain", "models")
58
+ entity_file.write_text(_ENTITY_TEMPLATE.format(class_name=names.pascal), encoding="utf-8")
59
+ console.print(
60
+ f"[green]✓[/green] Entité {names.pascal} créée : "
61
+ f"[bold]{entity_file.relative_to(project_dir)}[/bold]"
62
+ )
63
+ return entity_file
64
+
65
+
66
+ def add_usecase_cmd(*, project_dir: Path | None = None, usecase_name: str) -> Path:
67
+ project_dir = project_dir or Path.cwd()
68
+ usecase_name = usecase_name.strip()
69
+ _assert_project_root(project_dir, command="add-usecase")
70
+ _assert_valid_name(usecase_name, label="cas d'usage")
71
+
72
+ paths = detect_project_paths(project_dir)
73
+ names = UseCaseNames.from_input(usecase_name)
74
+ usecase_file = paths.application_use_cases / f"{names.snake}.py"
75
+ _assert_missing(usecase_file, project_dir)
76
+
77
+ _ensure_package_dirs(paths, "application", "use_cases")
78
+ usecase_file.write_text(_USE_CASE_TEMPLATE.format(class_name=names.pascal), encoding="utf-8")
79
+ console.print(
80
+ f"[green]✓[/green] Cas d'usage {names.pascal}UseCase créé : "
81
+ f"[bold]{usecase_file.relative_to(project_dir)}[/bold]"
82
+ )
83
+ return usecase_file
84
+
85
+
86
+ def _assert_project_root(project_dir: Path, *, command: str) -> None:
87
+ if not project_dir.exists() or not project_dir.is_dir():
88
+ console.print(f"[red]✗[/red] Répertoire introuvable : [bold]{project_dir}[/bold].")
89
+ raise typer.Exit(1)
90
+
91
+ has_project_marker = (
92
+ (project_dir / "pyproject.toml").exists()
93
+ or (project_dir / "src").is_dir()
94
+ or (project_dir / "config").is_dir()
95
+ )
96
+ if has_project_marker:
97
+ return
98
+
99
+ console.print(
100
+ f"[red]✗[/red] Aucun projet arclith détecté.\n"
101
+ f" Exécutez [bold]arclith-cli {command}[/bold] depuis la racine du projet."
102
+ )
103
+ raise typer.Exit(1)
104
+
105
+
106
+ def _assert_valid_name(raw: str, *, label: str) -> None:
107
+ if _NAME_RE.match(raw.strip()):
108
+ return
109
+
110
+ console.print(
111
+ f"[red]✗[/red] Nom de {label} invalide : [bold]{raw}[/bold]. "
112
+ "Lettres, chiffres, _ et - uniquement. Doit commencer par une lettre."
113
+ )
114
+ raise typer.Exit(1)
115
+
116
+
117
+ def _assert_missing(path: Path, project_dir: Path) -> None:
118
+ if not path.exists():
119
+ return
120
+
121
+ console.print(f"[red]✗[/red] Le fichier existe déjà : [bold]{path.relative_to(project_dir)}[/bold].")
122
+ raise typer.Exit(1)
123
+
124
+
125
+ def _ensure_package_dirs(paths: ProjectPaths, *relative_parts: str) -> None:
126
+ target_dir = paths.package_root.joinpath(*relative_parts)
127
+ target_dir.mkdir(parents=True, exist_ok=True)
128
+
129
+ stop_at = paths.package_root.parent if paths.package_name is not None else paths.package_root
130
+ init_dirs = [target_dir]
131
+ for parent in target_dir.parents:
132
+ if parent == stop_at:
133
+ break
134
+ init_dirs.append(parent)
135
+
136
+ for directory in init_dirs:
137
+ init_file = directory / "__init__.py"
138
+ if not init_file.exists():
139
+ init_file.write_text("", encoding="utf-8")
140
+
141
+
142
+ def _strip_pascal_suffix(value: str) -> str:
143
+ suffixes = ("UseCase", "Usecase")
144
+ while True:
145
+ stripped = value
146
+ for suffix in suffixes:
147
+ if stripped.endswith(suffix):
148
+ stripped = stripped[: -len(suffix)]
149
+ break
150
+ if stripped == value:
151
+ return value
152
+ value = stripped
153
+
154
+
155
+ def _strip_snake_suffix(value: str) -> str:
156
+ suffixes = ("_use_case", "_usecase")
157
+ while True:
158
+ stripped = value
159
+ for suffix in suffixes:
160
+ if stripped.endswith(suffix):
161
+ stripped = stripped[: -len(suffix)]
162
+ break
163
+ if stripped == value:
164
+ return value
165
+ value = stripped
@@ -15,6 +15,7 @@ from rich.tree import Tree
15
15
  from . import __version__
16
16
  from .add_adapter import add_adapter_cmd
17
17
  from .capabilities import CAPABILITY_CATALOG, capability_catalog_as_dict
18
+ from .core_scaffold import add_entity_cmd, add_usecase_cmd
18
19
  from .export_config import export_config_cmd
19
20
  from .rename import EntityNames, apply_rename
20
21
  from .scaffold import download_and_extract
@@ -121,7 +122,7 @@ def version() -> None:
121
122
  def add_adapter(
122
123
  capability: Annotated[
123
124
  str,
124
- typer.Option("--capability", help="Capacité cible: repository, agent ou observability"),
125
+ typer.Option("--capability", help="Capacité cible: repository, api, mcp, agent ou observability"),
125
126
  ] = "repository",
126
127
  adapter: Annotated[
127
128
  str | None,
@@ -178,6 +179,32 @@ def add_adapter(
178
179
  )
179
180
 
180
181
 
182
+ @app.command(name="add-entity")
183
+ def add_entity(
184
+ entity: Annotated[
185
+ str | None,
186
+ typer.Argument(
187
+ help="Nom de l'entité métier au singulier. Exemple : Recipe, recipe_step, meal-plan",
188
+ ),
189
+ ] = None,
190
+ ) -> None:
191
+ """Créer uniquement le fichier minimal d'une entité métier dans domain/models."""
192
+ add_entity_cmd(entity_name=entity or _prompt_entity())
193
+
194
+
195
+ @app.command(name="add-usecase")
196
+ def add_usecase(
197
+ usecase: Annotated[
198
+ str | None,
199
+ typer.Argument(
200
+ help="Nom du cas d'usage. Exemple : PlanShoppingList, find_by_name, import-catalog",
201
+ ),
202
+ ] = None,
203
+ ) -> None:
204
+ """Créer uniquement le fichier minimal d'un cas d'usage dans application/use_cases."""
205
+ add_usecase_cmd(usecase_name=usecase or _prompt_usecase())
206
+
207
+
181
208
  @app.command(name="capabilities")
182
209
  def capabilities(
183
210
  as_json: Annotated[
@@ -256,6 +283,21 @@ def _prompt_project() -> str:
256
283
  return value
257
284
 
258
285
 
286
+ def _prompt_usecase() -> str:
287
+ console.print("\n[bold]Cas d'usage[/bold] [dim](ex : PlanShoppingList, find_by_name)[/dim]")
288
+ while True:
289
+ value = Prompt.ask(" [bold green]Nom du cas d'usage[/bold green]").strip()
290
+ if not value:
291
+ console.print(" [red]Le nom ne peut pas être vide.[/red]")
292
+ elif not _ENTITY_RE.match(value):
293
+ console.print(
294
+ " [red]Caractères invalides.[/red] "
295
+ "[dim]Lettres, chiffres, _ et - uniquement. Doit commencer par une lettre.[/dim]"
296
+ )
297
+ else:
298
+ return value
299
+
300
+
259
301
  # ── Helpers ───────────────────────────────────────────────────────────────────
260
302
 
261
303
  def _split_entity_option(value: str | None) -> list[str] | None:
@@ -12,6 +12,10 @@ class ProjectPaths:
12
12
  def domain_models(self) -> Path:
13
13
  return self.package_root / "domain" / "models"
14
14
 
15
+ @property
16
+ def application_use_cases(self) -> Path:
17
+ return self.package_root / "application" / "use_cases"
18
+
15
19
  @property
16
20
  def adapters_outbound(self) -> Path:
17
21
  return self.package_root / "adapters" / "outbound"
@@ -33,13 +37,29 @@ def detect_project_paths(project_dir: Path) -> ProjectPaths:
33
37
  """Detect the importable application package for an Arclith project."""
34
38
  src_dir = project_dir / "src"
35
39
  if src_dir.exists():
36
- candidates = sorted(
40
+ model_candidates = sorted(
37
41
  child
38
42
  for child in src_dir.iterdir()
39
43
  if child.is_dir() and (child / "domain" / "models").exists()
40
44
  )
41
- if candidates:
42
- package_root = candidates[0]
45
+ if model_candidates:
46
+ package_root = model_candidates[0]
47
+ return ProjectPaths(root=project_dir, package_root=package_root, package_name=package_root.name)
48
+
49
+ package_candidates = sorted(
50
+ child
51
+ for child in src_dir.iterdir()
52
+ if child.is_dir() and _looks_like_package_root(child)
53
+ )
54
+ if package_candidates:
55
+ package_root = package_candidates[0]
43
56
  return ProjectPaths(root=project_dir, package_root=package_root, package_name=package_root.name)
44
57
 
45
58
  return ProjectPaths(root=project_dir, package_root=project_dir, package_name=None)
59
+
60
+
61
+ def _looks_like_package_root(path: Path) -> bool:
62
+ if (path / "__init__.py").exists():
63
+ return True
64
+
65
+ return any((path / layer).is_dir() for layer in ("domain", "application", "adapters", "infrastructure"))
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
  from dataclasses import dataclass
5
+ from importlib.metadata import PackageNotFoundError, version
5
6
  from pathlib import Path
6
7
 
7
8
  _SOURCE_PASCAL = "Ingredient"
@@ -89,9 +90,10 @@ def _patch_pyproject(target_dir: Path, project_name: str) -> None:
89
90
  p = target_dir / "pyproject.toml"
90
91
  if not p.exists():
91
92
  return
92
- text = p.read_text()
93
+ text = p.read_text(encoding="utf-8")
93
94
  # Update project name (first occurrence)
94
95
  text = re.sub(r'(?m)^name\s*=\s*"[^"]*"', f'name = "{project_name}"', text, count=1)
96
+ text = _patch_arclith_dependency(text)
95
97
  # Remove [tool.uv.sources] block (editable arclith path)
96
98
  text = re.sub(
97
99
  r"\[tool\.uv\.sources\]\n(?:[^\[]*)",
@@ -99,7 +101,26 @@ def _patch_pyproject(target_dir: Path, project_name: str) -> None:
99
101
  text,
100
102
  flags=re.DOTALL,
101
103
  )
102
- p.write_text(text.strip() + "\n")
104
+ p.write_text(text.strip() + "\n", encoding="utf-8")
105
+
106
+
107
+ def _patch_arclith_dependency(text: str) -> str:
108
+ framework_version = _installed_arclith_version()
109
+ if framework_version is None:
110
+ return text
111
+ return re.sub(
112
+ r'("arclith(?:\[[^"]+\])?>=)[^"]+(")',
113
+ lambda match: f"{match.group(1)}{framework_version}{match.group(2)}",
114
+ text,
115
+ count=1,
116
+ )
117
+
118
+
119
+ def _installed_arclith_version() -> str | None:
120
+ try:
121
+ return version("arclith")
122
+ except PackageNotFoundError:
123
+ return None
103
124
 
104
125
 
105
126
  # ── config/ directory patching ────────────────────────────────────────────────
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arclith-cli"
7
- version = "0.9.0"
7
+ version = "0.10.0"
8
8
  description = "CLI scaffolding tool for arclith — hexagonal architecture framework"
9
9
  license = { file = "LICENSE" }
10
10
  requires-python = ">=3.13"
@@ -16,7 +16,7 @@ dependencies = [
16
16
  "typer>=0.15.0",
17
17
  "rich>=13.0.0",
18
18
  "httpx>=0.27.0",
19
- "arclith>=0.12.0",
19
+ "arclith>=0.13.0",
20
20
  ]
21
21
 
22
22
  [tool.uv.sources]
@@ -39,7 +39,7 @@ if grep -q '\[tool\.uv\.sources\]' pyproject.toml; then
39
39
  fi
40
40
 
41
41
  ARCLITH_VERSION=$(grep 'arclith\[' pyproject.toml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
42
- MIN_ARCLITH_VERSION="0.12.0"
42
+ MIN_ARCLITH_VERSION="0.13.0"
43
43
  if version_lt "$ARCLITH_VERSION" "$MIN_ARCLITH_VERSION"; then
44
44
  echo -e "${RED}✗ FAIL: arclith version < $MIN_ARCLITH_VERSION (found: $ARCLITH_VERSION)${NC}"
45
45
  exit 1
@@ -194,6 +194,106 @@ def test_add_langsmith_skips_missing_empty_api_key(tmp_path: Path) -> None:
194
194
  assert "LANGSMITH_PROJECT=agent-tests" in env
195
195
 
196
196
 
197
+ def test_add_fastapi_api_adapter_generates_inbound_config_only(tmp_path: Path) -> None:
198
+ project_dir = _minimal_project(tmp_path)
199
+
200
+ add_adapter_cmd(
201
+ project_dir=project_dir,
202
+ capability_name="api",
203
+ adapter="fastapi",
204
+ adapter_params={
205
+ "host": "127.0.0.1",
206
+ "port": "8080",
207
+ "reload": "false",
208
+ },
209
+ yes=True,
210
+ )
211
+
212
+ config = (project_dir / "config" / "adapters" / "inbound" / "fastapi.yaml").read_text(
213
+ encoding="utf-8"
214
+ )
215
+ assert "host: 127.0.0.1" in config
216
+ assert "port: 8080" in config
217
+ assert "reload: false" in config
218
+ assert "repository: memory" in (project_dir / "config" / "adapters" / "adapters.yaml").read_text(
219
+ encoding="utf-8"
220
+ )
221
+ package_root = project_dir / "src" / "demo_service"
222
+ assert not (package_root / "adapters" / "outbound" / "fastapi").exists()
223
+
224
+
225
+ def test_add_fastmcp_mcp_adapter_generates_inbound_config_only(tmp_path: Path) -> None:
226
+ project_dir = _minimal_project(tmp_path)
227
+
228
+ add_adapter_cmd(
229
+ project_dir=project_dir,
230
+ capability_name="mcp",
231
+ adapter="fastmcp",
232
+ adapter_params={
233
+ "host": "127.0.0.1",
234
+ "port": "9001",
235
+ },
236
+ yes=True,
237
+ )
238
+
239
+ config = (project_dir / "config" / "adapters" / "inbound" / "fastmcp.yaml").read_text(
240
+ encoding="utf-8"
241
+ )
242
+ assert "host: 127.0.0.1" in config
243
+ assert "port: 9001" in config
244
+ assert "repository: memory" in (project_dir / "config" / "adapters" / "adapters.yaml").read_text(
245
+ encoding="utf-8"
246
+ )
247
+ package_root = project_dir / "src" / "demo_service"
248
+ assert not (package_root / "adapters" / "outbound" / "fastmcp").exists()
249
+
250
+
251
+ def test_add_opentelemetry_observability_adapter_uses_catalog_params(tmp_path: Path) -> None:
252
+ project_dir = _minimal_project(tmp_path)
253
+ (project_dir / ".env").write_text("EXISTING=value\n", encoding="utf-8")
254
+
255
+ add_adapter_cmd(
256
+ project_dir=project_dir,
257
+ capability_name="observability",
258
+ adapter="opentelemetry",
259
+ adapter_params={
260
+ "service_name": "demo-api",
261
+ "endpoint": "http://otel-collector:4318",
262
+ "protocol": "http/protobuf",
263
+ "traces": "true",
264
+ "metrics": "true",
265
+ "instrument_fastapi": "true",
266
+ "metrics_export_interval_millis": "15000",
267
+ "headers": "authorization=Bearer test",
268
+ },
269
+ yes=True,
270
+ )
271
+
272
+ config = (project_dir / "config" / "adapters" / "outbound" / "opentelemetry.yaml").read_text(
273
+ encoding="utf-8"
274
+ )
275
+ assert "enabled: true" in config
276
+ assert 'service_name: "demo-api"' in config
277
+ assert 'endpoint: "http://otel-collector:4318"' in config
278
+ assert 'protocol: "http/protobuf"' in config
279
+ assert "headers_env: OTEL_EXPORTER_OTLP_HEADERS" in config
280
+ assert "traces: true" in config
281
+ assert "metrics: true" in config
282
+ assert "instrument_fastapi: true" in config
283
+ assert "metrics_export_interval_millis: 15000" in config
284
+ assert "observability: opentelemetry" in (
285
+ project_dir / "config" / "adapters" / "adapters.yaml"
286
+ ).read_text(encoding="utf-8")
287
+
288
+ env = (project_dir / ".env").read_text(encoding="utf-8")
289
+ assert "EXISTING=value" in env
290
+ assert "OTEL_SERVICE_NAME=demo-api" in env
291
+ assert "OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318" in env
292
+ assert "OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf" in env
293
+ assert "OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer test" in env
294
+ assert ".env" in (project_dir / ".gitignore").read_text(encoding="utf-8")
295
+
296
+
197
297
  def test_add_langgraph_agent_adapter_generates_runtime_entrypoint(tmp_path: Path) -> None:
198
298
  project_dir = _minimal_project(tmp_path)
199
299
 
@@ -242,6 +342,19 @@ def test_add_non_entity_adapter_rejects_entity_selection(tmp_path: Path) -> None
242
342
  )
243
343
 
244
344
 
345
+ def test_add_transport_adapter_rejects_entity_selection(tmp_path: Path) -> None:
346
+ project_dir = _minimal_project(tmp_path)
347
+
348
+ with pytest.raises(typer.Exit):
349
+ add_adapter_cmd(
350
+ project_dir=project_dir,
351
+ capability_name="api",
352
+ adapter="fastapi",
353
+ entity_names=["Widget"],
354
+ yes=True,
355
+ )
356
+
357
+
245
358
  def test_add_adapter_rejects_unknown_catalog_param(tmp_path: Path) -> None:
246
359
  project_dir = _minimal_project(tmp_path)
247
360
 
@@ -23,7 +23,7 @@ def test_observability_capability_catalog_declares_langsmith() -> None:
23
23
  assert capability is not None
24
24
  assert capability.layer == "outbound"
25
25
  assert capability.activation_config_key == "observability"
26
- assert capability.adapter_names() == ("langsmith",)
26
+ assert capability.adapter_names() == ("langsmith", "opentelemetry")
27
27
  langsmith = capability.get_adapter("langsmith")
28
28
  assert langsmith is not None
29
29
  assert langsmith.config_path == "config/adapters/outbound/langsmith.yaml"
@@ -37,6 +37,56 @@ def test_observability_capability_catalog_declares_langsmith() -> None:
37
37
  ]
38
38
 
39
39
 
40
+ def test_api_capability_catalog_declares_fastapi() -> None:
41
+ capability = get_capability("api")
42
+
43
+ assert capability is not None
44
+ assert capability.layer == "inbound"
45
+ assert capability.activation_config_key is None
46
+ assert capability.adapter_names() == ("fastapi",)
47
+ fastapi = capability.get_adapter("fastapi")
48
+ assert fastapi is not None
49
+ assert fastapi.config_path == "config/adapters/inbound/fastapi.yaml"
50
+ assert fastapi.entity_scoped is False
51
+ assert [parameter.name for parameter in fastapi.parameters] == ["host", "port", "reload"]
52
+
53
+
54
+ def test_mcp_capability_catalog_declares_fastmcp() -> None:
55
+ capability = get_capability("mcp")
56
+
57
+ assert capability is not None
58
+ assert capability.layer == "inbound"
59
+ assert capability.activation_config_key is None
60
+ assert capability.adapter_names() == ("fastmcp",)
61
+ fastmcp = capability.get_adapter("fastmcp")
62
+ assert fastmcp is not None
63
+ assert fastmcp.config_path == "config/adapters/inbound/fastmcp.yaml"
64
+ assert fastmcp.entity_scoped is False
65
+ assert [parameter.name for parameter in fastmcp.parameters] == ["host", "port"]
66
+
67
+
68
+ def test_observability_capability_catalog_declares_opentelemetry() -> None:
69
+ capability = get_capability("observability")
70
+
71
+ assert capability is not None
72
+ opentelemetry = capability.get_adapter("opentelemetry")
73
+ assert opentelemetry is not None
74
+ assert opentelemetry.config_path == "config/adapters/outbound/opentelemetry.yaml"
75
+ assert opentelemetry.env_path == ".env"
76
+ assert opentelemetry.entity_scoped is False
77
+ assert [parameter.name for parameter in opentelemetry.parameters] == [
78
+ "enabled",
79
+ "service_name",
80
+ "endpoint",
81
+ "protocol",
82
+ "traces",
83
+ "metrics",
84
+ "instrument_fastapi",
85
+ "metrics_export_interval_millis",
86
+ "headers",
87
+ ]
88
+
89
+
40
90
  def test_agent_capability_catalog_declares_langgraph() -> None:
41
91
  capability = get_capability("agent")
42
92
 
@@ -89,10 +139,15 @@ def test_capability_catalog_is_json_serializable() -> None:
89
139
 
90
140
  assert "repository" in encoded
91
141
  assert "mongodb" in encoded
142
+ assert "api" in encoded
143
+ assert "fastapi" in encoded
144
+ assert "mcp" in encoded
145
+ assert "fastmcp" in encoded
92
146
  assert "agent" in encoded
93
147
  assert "langgraph" in encoded
94
148
  assert "observability" in encoded
95
149
  assert "langsmith" in encoded
150
+ assert "opentelemetry" in encoded
96
151
 
97
152
 
98
153
  def test_capabilities_command_outputs_json_catalog() -> None:
@@ -107,7 +162,11 @@ def test_capabilities_command_outputs_json_catalog() -> None:
107
162
  payload = json.loads(result.stdout)
108
163
  assert payload[0]["name"] == "repository"
109
164
  assert [adapter["name"] for adapter in payload[0]["adapters"]] == ["memory", "mongodb", "duckdb", "mariadb"]
110
- assert payload[1]["name"] == "agent"
111
- assert [adapter["name"] for adapter in payload[1]["adapters"]] == ["langgraph"]
112
- assert payload[2]["name"] == "observability"
113
- assert [adapter["name"] for adapter in payload[2]["adapters"]] == ["langsmith"]
165
+ assert payload[1]["name"] == "api"
166
+ assert [adapter["name"] for adapter in payload[1]["adapters"]] == ["fastapi"]
167
+ assert payload[2]["name"] == "mcp"
168
+ assert [adapter["name"] for adapter in payload[2]["adapters"]] == ["fastmcp"]
169
+ assert payload[3]["name"] == "agent"
170
+ assert [adapter["name"] for adapter in payload[3]["adapters"]] == ["langgraph"]
171
+ assert payload[4]["name"] == "observability"
172
+ assert [adapter["name"] for adapter in payload[4]["adapters"]] == ["langsmith", "opentelemetry"]
@@ -0,0 +1,91 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ import typer
5
+
6
+ from arclith_cli.core_scaffold import add_entity_cmd, add_usecase_cmd
7
+
8
+
9
+ def _src_project(tmp_path: Path, package_name: str = "demo_service") -> Path:
10
+ project_dir = tmp_path / "demo-service"
11
+ package_root = project_dir / "src" / package_name
12
+ package_root.mkdir(parents=True)
13
+ (package_root / "__init__.py").write_text("", encoding="utf-8")
14
+ (project_dir / "pyproject.toml").write_text('[project]\nname = "demo-service"\n', encoding="utf-8")
15
+ return project_dir
16
+
17
+
18
+ def test_add_entity_creates_minimal_entity_in_src_package(tmp_path: Path) -> None:
19
+ project_dir = _src_project(tmp_path)
20
+
21
+ generated = add_entity_cmd(project_dir=project_dir, entity_name="ShoppingItem")
22
+
23
+ assert generated == project_dir / "src" / "demo_service" / "domain" / "models" / "shopping_item.py"
24
+ assert generated.read_text(encoding="utf-8") == (
25
+ "from arclith.domain.models.entity import Entity\n"
26
+ "\n"
27
+ "\n"
28
+ "class ShoppingItem(Entity):\n"
29
+ " pass\n"
30
+ )
31
+ assert (project_dir / "src" / "demo_service" / "domain" / "__init__.py").exists()
32
+ assert (project_dir / "src" / "demo_service" / "domain" / "models" / "__init__.py").exists()
33
+ assert not (project_dir / "src" / "demo_service" / "adapters").exists()
34
+
35
+
36
+ def test_add_usecase_creates_minimal_usecase_in_src_package(tmp_path: Path) -> None:
37
+ project_dir = _src_project(tmp_path)
38
+
39
+ generated = add_usecase_cmd(project_dir=project_dir, usecase_name="PlanShoppingList")
40
+
41
+ assert generated == project_dir / "src" / "demo_service" / "application" / "use_cases" / "plan_shopping_list.py"
42
+ assert generated.read_text(encoding="utf-8") == (
43
+ "class PlanShoppingListUseCase:\n"
44
+ " async def execute(self) -> None:\n"
45
+ ' raise NotImplementedError("Implement PlanShoppingListUseCase.execute")\n'
46
+ )
47
+ assert (project_dir / "src" / "demo_service" / "application" / "__init__.py").exists()
48
+ assert (project_dir / "src" / "demo_service" / "application" / "use_cases" / "__init__.py").exists()
49
+ assert not (project_dir / "src" / "demo_service" / "adapters").exists()
50
+
51
+
52
+ def test_add_usecase_strips_repeated_usecase_suffix(tmp_path: Path) -> None:
53
+ project_dir = _src_project(tmp_path)
54
+
55
+ generated = add_usecase_cmd(project_dir=project_dir, usecase_name="plan-shopping-list-use-case-usecase")
56
+
57
+ assert generated.name == "plan_shopping_list.py"
58
+ assert "class PlanShoppingListUseCase:" in generated.read_text(encoding="utf-8")
59
+ assert "UseCaseUseCase" not in generated.read_text(encoding="utf-8")
60
+
61
+
62
+ def test_add_entity_supports_legacy_root_layout(tmp_path: Path) -> None:
63
+ project_dir = tmp_path / "legacy-service"
64
+ project_dir.mkdir(parents=True)
65
+ (project_dir / "pyproject.toml").write_text('[project]\nname = "legacy-service"\n', encoding="utf-8")
66
+
67
+ generated = add_entity_cmd(project_dir=project_dir, entity_name="Recipe")
68
+
69
+ assert generated == project_dir / "domain" / "models" / "recipe.py"
70
+ assert (project_dir / "domain" / "__init__.py").exists()
71
+ assert (project_dir / "domain" / "models" / "__init__.py").exists()
72
+ assert not (project_dir / "__init__.py").exists()
73
+
74
+
75
+ def test_add_entity_refuses_to_overwrite_existing_file(tmp_path: Path) -> None:
76
+ project_dir = _src_project(tmp_path)
77
+ existing = project_dir / "src" / "demo_service" / "domain" / "models" / "recipe.py"
78
+ existing.parent.mkdir(parents=True)
79
+ existing.write_text("class Recipe:\n pass\n", encoding="utf-8")
80
+
81
+ with pytest.raises(typer.Exit):
82
+ add_entity_cmd(project_dir=project_dir, entity_name="Recipe")
83
+
84
+ assert existing.read_text(encoding="utf-8") == "class Recipe:\n pass\n"
85
+
86
+
87
+ def test_add_usecase_rejects_invalid_name(tmp_path: Path) -> None:
88
+ project_dir = _src_project(tmp_path)
89
+
90
+ with pytest.raises(typer.Exit):
91
+ add_usecase_cmd(project_dir=project_dir, usecase_name="123-import")
@@ -136,7 +136,63 @@ def test_scaffold_and_run(temp_workspace: Path):
136
136
  for fname in expected_files:
137
137
  assert (project_dir / fname).exists(), f"Missing expected file: {fname}"
138
138
 
139
- # Step 6 — validate non-interactive adapter generation through the CLI entry point
139
+ # Step 6 — validate core scaffolding through the CLI entry point
140
+ result = subprocess.run(
141
+ [
142
+ "arclith-cli",
143
+ "add-entity",
144
+ "ShoppingItem",
145
+ ],
146
+ cwd=project_dir,
147
+ capture_output=True,
148
+ text=True,
149
+ timeout=30,
150
+ )
151
+ assert result.returncode == 0, f"add-entity failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
152
+ assert (
153
+ project_dir / "src" / "test_plan_service" / "domain" / "models" / "shopping_item.py"
154
+ ).exists()
155
+
156
+ result = subprocess.run(
157
+ [
158
+ "arclith-cli",
159
+ "add-usecase",
160
+ "PlanShoppingList",
161
+ ],
162
+ cwd=project_dir,
163
+ capture_output=True,
164
+ text=True,
165
+ timeout=30,
166
+ )
167
+ assert result.returncode == 0, f"add-usecase failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
168
+ assert (
169
+ project_dir / "src" / "test_plan_service" / "application" / "use_cases" / "plan_shopping_list.py"
170
+ ).exists()
171
+
172
+ import_script = "\n".join(
173
+ [
174
+ "from test_plan_service.domain.models.shopping_item import ShoppingItem",
175
+ "from test_plan_service.application.use_cases.plan_shopping_list import PlanShoppingListUseCase",
176
+ "print(ShoppingItem.__name__, PlanShoppingListUseCase.__name__)",
177
+ ]
178
+ )
179
+ result = subprocess.run(
180
+ [
181
+ "uv",
182
+ "run",
183
+ "python",
184
+ "-c",
185
+ import_script,
186
+ ],
187
+ cwd=project_dir,
188
+ capture_output=True,
189
+ text=True,
190
+ timeout=30,
191
+ )
192
+ assert result.returncode == 0, f"Core scaffold import failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
193
+ assert "ShoppingItem PlanShoppingListUseCase" in result.stdout
194
+
195
+ # Step 7 — validate non-interactive adapter generation through the CLI entry point
140
196
  result = subprocess.run(
141
197
  [
142
198
  "arclith-cli",
@@ -12,11 +12,25 @@ def test_detect_project_paths_uses_src_package(tmp_path: Path):
12
12
  assert paths.package_root == package_root
13
13
  assert paths.package_name == "my_service"
14
14
  assert paths.domain_models == package_root / "domain" / "models"
15
+ assert paths.application_use_cases == package_root / "application" / "use_cases"
15
16
  assert paths.adapters_outbound == package_root / "adapters" / "outbound"
16
17
  assert paths.containers == package_root / "infrastructure" / "containers"
17
18
  assert paths.import_path("domain", "models", "recipe") == "my_service.domain.models.recipe"
18
19
 
19
20
 
21
+ def test_detect_project_paths_uses_src_package_before_domain_exists(tmp_path: Path):
22
+ package_root = tmp_path / "src" / "my_service"
23
+ package_root.mkdir(parents=True)
24
+ (package_root / "__init__.py").write_text("", encoding="utf-8")
25
+
26
+ paths = detect_project_paths(tmp_path)
27
+
28
+ assert paths.package_root == package_root
29
+ assert paths.package_name == "my_service"
30
+ assert paths.domain_models == package_root / "domain" / "models"
31
+ assert paths.application_use_cases == package_root / "application" / "use_cases"
32
+
33
+
20
34
  def test_detect_project_paths_supports_root_layout(tmp_path: Path):
21
35
  (tmp_path / "domain" / "models").mkdir(parents=True)
22
36
 
@@ -24,4 +38,6 @@ def test_detect_project_paths_supports_root_layout(tmp_path: Path):
24
38
 
25
39
  assert paths.package_root == tmp_path
26
40
  assert paths.package_name is None
41
+ assert paths.domain_models == tmp_path / "domain" / "models"
42
+ assert paths.application_use_cases == tmp_path / "application" / "use_cases"
27
43
  assert paths.import_path("domain", "models", "recipe") == "domain.models.recipe"
@@ -1,6 +1,7 @@
1
+ from importlib.metadata import version
1
2
  from pathlib import Path
2
3
 
3
- from arclith_cli.rename import EntityNames, apply_rename
4
+ from arclith_cli.rename import EntityNames, _patch_arclith_dependency, apply_rename
4
5
 
5
6
 
6
7
  def test_apply_rename_updates_src_package_and_imports(tmp_path: Path):
@@ -16,6 +17,7 @@ def test_apply_rename_updates_src_package_and_imports(tmp_path: Path):
16
17
  pyproject.write_text(
17
18
  'name = "arclith-sample"\n'
18
19
  'packages = ["src/arclith_sample"]\n'
20
+ 'dependencies = ["arclith[all]>=0.12.0"]\n'
19
21
  "[tool.uv.sources]\n"
20
22
  'arclith = { path = "../arclith", editable = true }\n',
21
23
  encoding="utf-8",
@@ -29,4 +31,13 @@ def test_apply_rename_updates_src_package_and_imports(tmp_path: Path):
29
31
  patched_pyproject = pyproject.read_text(encoding="utf-8")
30
32
  assert 'name = "my-recipe-service"' in patched_pyproject
31
33
  assert 'packages = ["src/my_recipe_service"]' in patched_pyproject
34
+ assert f'"arclith[all]>={version("arclith")}"' in patched_pyproject
32
35
  assert "[tool.uv.sources]" not in patched_pyproject
36
+
37
+
38
+ def test_patch_arclith_dependency_keeps_template_requirement_when_version_is_unknown(monkeypatch):
39
+ monkeypatch.setattr("arclith_cli.rename._installed_arclith_version", lambda: None)
40
+
41
+ text = 'dependencies = ["arclith[all]>=0.12.0"]\n'
42
+
43
+ assert _patch_arclith_dependency(text) == text
@@ -46,7 +46,7 @@ wheels = [
46
46
 
47
47
  [[package]]
48
48
  name = "arclith"
49
- version = "0.12.0"
49
+ version = "0.13.0"
50
50
  source = { editable = "../" }
51
51
  dependencies = [
52
52
  { name = "fastapi" },
@@ -84,6 +84,16 @@ requires-dist = [
84
84
  { name = "loguru", specifier = "==0.7.3" },
85
85
  { name = "motor", marker = "extra == 'all'", specifier = "==3.7.1" },
86
86
  { name = "motor", marker = "extra == 'mongodb'", specifier = "==3.7.1" },
87
+ { name = "opentelemetry-api", marker = "extra == 'all'", specifier = ">=1.30.0" },
88
+ { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = ">=1.30.0" },
89
+ { name = "opentelemetry-exporter-otlp", marker = "extra == 'all'", specifier = ">=1.30.0" },
90
+ { name = "opentelemetry-exporter-otlp", marker = "extra == 'opentelemetry'", specifier = ">=1.30.0" },
91
+ { name = "opentelemetry-instrumentation-fastapi", marker = "extra == 'all'", specifier = ">=0.51b0" },
92
+ { name = "opentelemetry-instrumentation-fastapi", marker = "extra == 'opentelemetry'", specifier = ">=0.51b0" },
93
+ { name = "opentelemetry-instrumentation-logging", marker = "extra == 'all'", specifier = ">=0.51b0" },
94
+ { name = "opentelemetry-instrumentation-logging", marker = "extra == 'opentelemetry'", specifier = ">=0.51b0" },
95
+ { name = "opentelemetry-sdk", marker = "extra == 'all'", specifier = ">=1.30.0" },
96
+ { name = "opentelemetry-sdk", marker = "extra == 'opentelemetry'", specifier = ">=1.30.0" },
87
97
  { name = "pydantic", specifier = "==2.12.5" },
88
98
  { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'all'", specifier = ">=2.24.0,<3.0.0" },
89
99
  { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'langgraph'", specifier = ">=2.24.0,<3.0.0" },
@@ -100,7 +110,7 @@ requires-dist = [
100
110
  { name = "uvicorn", marker = "extra == 'all'", specifier = "==0.41.0" },
101
111
  { name = "uvicorn", marker = "extra == 'fastapi'", specifier = "==0.41.0" },
102
112
  ]
103
- provides-extras = ["mongodb", "duckdb", "mariadb", "fastapi", "mcp", "vault", "cache", "auth", "langgraph", "all"]
113
+ provides-extras = ["mongodb", "duckdb", "mariadb", "fastapi", "mcp", "vault", "cache", "auth", "langgraph", "opentelemetry", "all"]
104
114
 
105
115
  [package.metadata.requires-dev]
106
116
  dev = [
@@ -118,7 +128,7 @@ dev = [
118
128
 
119
129
  [[package]]
120
130
  name = "arclith-cli"
121
- version = "0.9.0"
131
+ version = "0.10.0"
122
132
  source = { editable = "." }
123
133
  dependencies = [
124
134
  { name = "arclith" },
@@ -1 +0,0 @@
1
- __version__ = "0.9.0"
File without changes
File without changes
File without changes