symfonia-ai-tools 1.7.0 → 1.8.0

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.
package/README.md CHANGED
@@ -73,6 +73,7 @@ Instead of choosing a single stack, you pick any combination of instruction & sk
73
73
  | **Vitest** | 1 instruction | Yes |
74
74
  | **Storybook** | 1 instruction | No |
75
75
  | **Laravel + PHP** | 4 instructions, 7 skills | No |
76
+ | **Python FastAPI** | 4 instructions, 7 skills | No |
76
77
  | **Playwright E2E** | 1 instruction, 3 skills | No |
77
78
  | **Docker** | 1 instruction | No |
78
79
 
@@ -155,6 +156,15 @@ When AI opens/edits a file matching `applyTo`, it automatically loads those inst
155
156
  | `api-resource.instructions.md` | `**/*Resource*.php, **/*Request*.php` | Resources with whenLoaded(), Form Requests, Policies |
156
157
  | `testing.instructions.md` | `**/*Test*.php, **/tests/**` | PHPUnit, data providers, factories, Laravel fakes |
157
158
 
159
+ ### Python FastAPI (4 instructions)
160
+
161
+ | Instruction | applyTo | What it teaches AI |
162
+ |-------------|---------|-------------------|
163
+ | `api-schema.instructions.md` | `**/schemas.py, **/*schema*.py, **/*request*.py, **/*response*.py` | Pydantic v2 schemas, StrEnum status fields, `PaginatedResponse[T]`, frozen request models, `ErrorResponse` |
164
+ | `module.instructions.md` | `**/modules/**/router.py, **/modules/**/dependencies.py, **/modules/**/domain/**, **/modules/**/infrastructure/**` | Module structure, FastAPI router pattern, SQLAlchemy 2.0 ORM models, `lifespan=` setup, DI wiring |
165
+ | `service-repository.instructions.md` | `**/domain/services.py, **/infrastructure/repositories/**, **/domain/entities.py, **/domain/value_objects.py` | CQRS repos, domain entities, EventPublisher pattern, layer isolation rules |
166
+ | `testing.instructions.md` | `**/tests/**, **/*test*.py, **/conftest.py` | pytest-asyncio conftest, `async_sessionmaker`, rollback pattern, `AsyncClient` |
167
+
158
168
  ### Playwright (1 instruction)
159
169
 
160
170
  | Instruction | applyTo | What it teaches AI |
@@ -209,16 +219,21 @@ Skills are ready-made procedures that AI executes step by step. Each skill has a
209
219
 
210
220
  ### How to use
211
221
 
212
- Tell the AI:
222
+ **Claude Code** — skills are auto-discovered as slash commands:
223
+ ```
224
+ /smf-debug
225
+ /smf-wtf
226
+ /smf-new-module
227
+ ```
213
228
 
229
+ **GitHub Copilot, Cursor, Gemini, Junie** — type in chat:
214
230
  ```
215
- "Using skill smf-new-module, create a users module"
216
- "Run skill smf-debug for the TypeError in UserService"
217
- "Follow skill smf-pr-prepare"
218
- "wtf" — quick session status
231
+ use smf-debug
232
+ use smf-new-module to create a users module
233
+ use smf-wtf
219
234
  ```
220
235
 
221
- AI reads `SKILL.md` and executes the steps sequentially.
236
+ The agent reads `SKILL.md` and executes the steps sequentially.
222
237
 
223
238
  All skills have the `smf-` prefix (Symfonia). The configurator lets you choose which skills to install with checkboxes.
224
239
 
@@ -262,6 +277,18 @@ All skills have the `smf-` prefix (Symfonia). The configurator lets you choose w
262
277
  | `smf-testing-unit` | Unit tests | Isolated tests without DB, PHPUnit mocks, data providers |
263
278
  | `smf-testing-manual` | Manual HTTP tests | JetBrains HTTP Client — `.http` files for interactive API testing |
264
279
 
280
+ ### Python FastAPI skills (7 skills)
281
+
282
+ | Skill | Purpose | When to use |
283
+ |-------|---------|-------------|
284
+ | `smf-new-module` | New module from scratch | router → schemas → ORM model → CQRS repos → service → migration → tests |
285
+ | `smf-new-endpoint` | New endpoint in existing module | request schema → route → service method → response schema → tests |
286
+ | `smf-migration` | Alembic migration | autogenerate → review → upgrade → verify; async env.py setup |
287
+ | `smf-background-task` | Celery background task | task def (sync) → `SyncSessionLocal` → dispatch from router → beat schedule → tests |
288
+ | `smf-testing-integration` | Integration tests (HTTP) | `AsyncClient` → real DB via rollback conftest → router assertions |
289
+ | `smf-testing-unit` | Unit tests (service) | mock repos → service logic → no DB needed |
290
+ | `smf-testing-manual` | Manual HTTP tests | JetBrains HTTP Client `.http` files for interactive API testing |
291
+
265
292
  ### Playwright skills (3 skills)
266
293
 
267
294
  | Skill | Purpose | When to use |
@@ -519,6 +546,7 @@ templates/
519
546
  ├── vitest/ # 1 instr.
520
547
  ├── storybook/ # 1 instr.
521
548
  ├── laravel/ # 4 instr. · 7 skills
549
+ ├── python-fastapi/ # 4 instr. · 7 skills
522
550
  ├── playwright/ # 1 instr. · 3 skills
523
551
  └── docker/ # 1 instr.
524
552
  ```
package/lib/questions.mjs CHANGED
@@ -156,9 +156,10 @@ export async function askQuestions(packsDir) {
156
156
 
157
157
  const hasVue = answers.packs.includes('vue3');
158
158
  const hasLaravel = answers.packs.includes('laravel');
159
- answers.testCommand = await ask(t('q.cmd.test'), hasVue ? 'npm run test' : hasLaravel ? 'php artisan test' : 'npm test');
160
- answers.buildCommand = await ask(t('q.cmd.build'), hasVue ? 'npm run build' : hasLaravel ? 'composer build' : 'npm run build');
161
- answers.lintCommand = await ask(t('q.cmd.lint'), hasVue ? 'npm run lint' : hasLaravel ? 'php artisan pint' : 'npm run lint');
159
+ const hasPythonFastapi = answers.packs.includes('python-fastapi');
160
+ answers.testCommand = await ask(t('q.cmd.test'), hasVue ? 'npm run test' : hasLaravel ? 'php artisan test' : hasPythonFastapi ? 'pytest' : 'npm test');
161
+ answers.buildCommand = await ask(t('q.cmd.build'), hasVue ? 'npm run build' : hasLaravel ? 'composer build' : hasPythonFastapi ? 'docker build .' : 'npm run build');
162
+ answers.lintCommand = await ask(t('q.cmd.lint'), hasVue ? 'npm run lint' : hasLaravel ? 'php artisan pint' : hasPythonFastapi ? 'ruff check . && mypy .' : 'npm run lint');
162
163
 
163
164
  // --- CI ---
164
165
  section(t('q.section.ci'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symfonia-ai-tools",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "AI tooling setup for your project - Claude Code, GitHub Copilot, Cursor, Gemini, Junie, GSD",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,177 @@
1
+ ---
2
+ applyTo: "**/schemas.py,**/*schema*.py,**/*request*.py,**/*response*.py"
3
+ ---
4
+
5
+ # API Schemas, Request & Response Models — Instructions
6
+
7
+ ## Pydantic v2 Schemas
8
+
9
+ Never return ORM models directly from endpoints. All input/output passes through Pydantic schemas.
10
+
11
+ ```python
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime
15
+ from uuid import UUID
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+
20
+ class FeatureResponse(BaseModel):
21
+ model_config = ConfigDict(from_attributes=True)
22
+
23
+ id: UUID
24
+ name: str
25
+ description: str | None
26
+ status: FeatureStatus # use the StrEnum — not bare `str`
27
+ created_at: datetime
28
+
29
+ # Nested relations — use None default (loaded on demand)
30
+ author: UserResponse | None = None
31
+ category: CategoryResponse | None = None
32
+ ```
33
+
34
+ ### Request Schemas (Input Validation)
35
+
36
+ Request schemas are **read-only once validated** — add `frozen=True` to catch accidental mutation:
37
+
38
+ ```python
39
+ from __future__ import annotations
40
+
41
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
42
+ from uuid import UUID
43
+
44
+
45
+ class StoreFeatureRequest(BaseModel):
46
+ model_config = ConfigDict(frozen=True)
47
+
48
+ name: str = Field(min_length=2, max_length=255)
49
+ description: str | None = Field(default=None, max_length=5000)
50
+ category_id: UUID | None = None
51
+ status: FeatureStatus
52
+ tags: list[str] = Field(default_factory=list)
53
+
54
+ @field_validator("tags")
55
+ @classmethod
56
+ def validate_tags(cls, v: list[str]) -> list[str]:
57
+ if any(len(tag) > 50 for tag in v):
58
+ raise ValueError("Each tag must be at most 50 characters")
59
+ return v
60
+
61
+
62
+ class UpdateFeatureRequest(BaseModel):
63
+ model_config = ConfigDict(frozen=True)
64
+
65
+ name: str | None = Field(default=None, min_length=2, max_length=255)
66
+ description: str | None = None
67
+ status: FeatureStatus | None = None
68
+ ```
69
+
70
+ ### List / Index Request with Filtering and Sorting
71
+
72
+ ```python
73
+ from __future__ import annotations
74
+
75
+ from typing import Literal
76
+ from pydantic import BaseModel, Field
77
+
78
+
79
+ class IndexFeatureRequest(BaseModel):
80
+ page: int = Field(default=1, ge=1)
81
+ limit: int = Field(default=20, ge=1, le=100)
82
+ status: FeatureStatus | None = None
83
+ sort_by: Literal["created_at", "name", "status"] = "created_at"
84
+ sort_dir: Literal["asc", "desc"] = "desc"
85
+ ```
86
+
87
+ ### Paginated Collection Response
88
+
89
+ `PaginatedResponse` and `PaginationMeta` are **shared types** — define them once in `app/core/schemas.py`, never per-module:
90
+
91
+ ```python
92
+ # app/core/schemas.py
93
+ from __future__ import annotations
94
+
95
+ from typing import Generic, TypeVar
96
+ from pydantic import BaseModel
97
+
98
+ T = TypeVar("T")
99
+
100
+
101
+ class PaginationMeta(BaseModel):
102
+ total: int
103
+ per_page: int
104
+ current_page: int
105
+ last_page: int
106
+
107
+
108
+ class PaginatedResponse(BaseModel, Generic[T]):
109
+ data: list[T]
110
+ meta: PaginationMeta
111
+ ```
112
+
113
+ Import from `app.core.schemas` in every module that needs it.
114
+
115
+ ## Error Responses
116
+
117
+ Standardize the error body shape. Define `ErrorResponse` in `app/core/schemas.py`:
118
+
119
+ ```python
120
+ # app/core/schemas.py
121
+ class ErrorResponse(BaseModel):
122
+ code: str # machine-readable code: "FEATURE_NOT_FOUND"
123
+ message: str # human-readable description
124
+ field: str | None = None # which field caused the error (optional)
125
+ ```
126
+
127
+ Raise via `HTTPException` — the exception handler converts it:
128
+
129
+ ```python
130
+ from fastapi import HTTPException, status
131
+
132
+ # In service (expected domain errors):
133
+ raise HTTPException(
134
+ status_code=status.HTTP_404_NOT_FOUND,
135
+ detail={"code": "FEATURE_NOT_FOUND", "message": "Feature not found"},
136
+ )
137
+ ```
138
+
139
+ Register a handler in `app/main.py` to enforce consistent shape:
140
+
141
+ ```python
142
+ from fastapi import Request
143
+ from fastapi.responses import JSONResponse
144
+
145
+ @app.exception_handler(HTTPException)
146
+ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
147
+ if isinstance(exc.detail, dict):
148
+ body = exc.detail
149
+ else:
150
+ body = {"code": "HTTP_ERROR", "message": str(exc.detail)}
151
+ return JSONResponse(status_code=exc.status_code, content=body)
152
+ ```
153
+
154
+ ## Enums
155
+
156
+ ```python
157
+ from enum import StrEnum
158
+
159
+
160
+ class FeatureStatus(StrEnum):
161
+ DRAFT = "draft"
162
+ ACTIVE = "active"
163
+ ARCHIVED = "archived"
164
+ ```
165
+
166
+ Use `StrEnum` (Python 3.11+) — auto-serializes to string in JSON.
167
+
168
+ ## Rules
169
+
170
+ - `from_attributes=True` on every response schema that reads from ORM
171
+ - Never expose internal IDs as integers — use UUID
172
+ - Use `Field(...)` for constraints, not just annotations
173
+ - `StrEnum` for all enums — clean JSON serialization, use in both request and response schemas
174
+ - Nested relations are always `| None = None` — populate only when loaded
175
+ - `frozen=True` on all request/input schemas — prevents accidental mutation after validation
176
+ - `PaginatedResponse[T]` and `PaginationMeta` live in `app/core/schemas.py` — import, never redefine
177
+ - `ErrorResponse` body has `code` (machine) and `message` (human) — register a global exception handler
@@ -0,0 +1,211 @@
1
+ ---
2
+ applyTo: "**/modules/**/router.py,**/modules/**/dependencies.py,**/modules/**/domain/**,**/modules/**/infrastructure/**"
3
+ ---
4
+
5
+ # Modular Architecture — Instructions
6
+
7
+ ## Module Structure
8
+
9
+ Each business domain has its own directory under `modules/`:
10
+
11
+ ```
12
+ modules/feature/
13
+ ├── __init__.py
14
+ ├── router.py # FastAPI APIRouter — thin, no logic
15
+ ├── dependencies.py # Depends() factories for DI
16
+ ├── domain/
17
+ │ ├── __init__.py
18
+ │ ├── entities.py # Domain entities (pure Python dataclasses)
19
+ │ ├── schemas.py # Pydantic v2 request/response schemas
20
+ │ ├── services.py # Business logic
21
+ │ ├── events.py # [optional] Domain events (dataclasses)
22
+ │ └── value_objects.py # [optional] Value objects (frozen dataclasses)
23
+ ├── infrastructure/
24
+ │ ├── __init__.py
25
+ │ ├── orm.py # SQLAlchemy ORM mapped classes
26
+ │ ├── repositories/
27
+ │ │ ├── __init__.py
28
+ │ │ ├── base.py # Abstract base classes (interfaces)
29
+ │ │ ├── command_repository.py # SQLAlchemy write implementation
30
+ │ │ └── query_repository.py # SQLAlchemy read implementation
31
+ │ └── tasks.py # [optional] Celery tasks for this module
32
+ └── tests/
33
+ ├── __init__.py
34
+ ├── conftest.py # Module-level fixtures
35
+ ├── integration/
36
+ │ ├── __init__.py
37
+ │ └── test_feature_router.py
38
+ └── unit/
39
+ ├── __init__.py
40
+ └── test_feature_service.py
41
+ ```
42
+
43
+ > **Optional files** (`events.py`, `value_objects.py`, `tasks.py`) — create only when needed. Do not scaffold files that will stay empty.
44
+
45
+ ## Router (`router.py`)
46
+
47
+ ```python
48
+ from __future__ import annotations
49
+
50
+ from uuid import UUID
51
+
52
+ from fastapi import APIRouter, Depends, status
53
+
54
+ from app.core.auth import get_current_user
55
+ from modules.feature.dependencies import get_feature_service
56
+ from modules.feature.domain.schemas import (
57
+ FeatureResponse,
58
+ IndexFeatureRequest,
59
+ PaginatedResponse,
60
+ StoreFeatureRequest,
61
+ UpdateFeatureRequest,
62
+ )
63
+ from modules.feature.domain.services import FeatureService
64
+
65
+ router = APIRouter(prefix="/features", tags=["features"])
66
+
67
+
68
+ @router.get("", response_model=PaginatedResponse[FeatureResponse])
69
+ async def index(
70
+ params: IndexFeatureRequest = Depends(),
71
+ current_user=Depends(get_current_user),
72
+ service: FeatureService = Depends(get_feature_service),
73
+ ) -> PaginatedResponse[FeatureResponse]:
74
+ return await service.list_for_user(current_user.id, params)
75
+
76
+
77
+ @router.post("", response_model=FeatureResponse, status_code=status.HTTP_201_CREATED)
78
+ async def store(
79
+ body: StoreFeatureRequest,
80
+ current_user=Depends(get_current_user),
81
+ service: FeatureService = Depends(get_feature_service),
82
+ ) -> FeatureResponse:
83
+ return await service.create(current_user.id, body)
84
+
85
+
86
+ @router.get("/{feature_id}", response_model=FeatureResponse)
87
+ async def show(
88
+ feature_id: UUID,
89
+ current_user=Depends(get_current_user),
90
+ service: FeatureService = Depends(get_feature_service),
91
+ ) -> FeatureResponse:
92
+ return await service.get_or_404(feature_id, current_user.id)
93
+
94
+
95
+ @router.patch("/{feature_id}", response_model=FeatureResponse)
96
+ async def update(
97
+ feature_id: UUID,
98
+ body: UpdateFeatureRequest,
99
+ current_user=Depends(get_current_user),
100
+ service: FeatureService = Depends(get_feature_service),
101
+ ) -> FeatureResponse:
102
+ return await service.update(feature_id, current_user.id, body)
103
+
104
+
105
+ @router.delete("/{feature_id}", status_code=status.HTTP_204_NO_CONTENT)
106
+ async def destroy(
107
+ feature_id: UUID,
108
+ current_user=Depends(get_current_user),
109
+ service: FeatureService = Depends(get_feature_service),
110
+ ) -> None:
111
+ await service.delete(feature_id, current_user.id)
112
+ ```
113
+
114
+ ## Dependencies (`dependencies.py`)
115
+
116
+ ```python
117
+ from __future__ import annotations
118
+
119
+ from fastapi import Depends
120
+ from sqlalchemy.ext.asyncio import AsyncSession
121
+
122
+ from app.core.database import get_db
123
+ from modules.feature.domain.services import FeatureService
124
+ from modules.feature.infrastructure.repositories.command_repository import (
125
+ SqlAlchemyFeatureCommandRepository,
126
+ )
127
+ from modules.feature.infrastructure.repositories.query_repository import (
128
+ SqlAlchemyFeatureQueryRepository,
129
+ )
130
+
131
+
132
+ def get_feature_service(
133
+ db: AsyncSession = Depends(get_db),
134
+ ) -> FeatureService:
135
+ return FeatureService(
136
+ command_repo=SqlAlchemyFeatureCommandRepository(db),
137
+ query_repo=SqlAlchemyFeatureQueryRepository(db),
138
+ )
139
+ ```
140
+
141
+ ## Router Registration (`app/main.py`)
142
+
143
+ ```python
144
+ from contextlib import asynccontextmanager
145
+ from fastapi import FastAPI
146
+ from app.core.config import settings
147
+ from modules.feature.router import router as feature_router
148
+
149
+
150
+ @asynccontextmanager
151
+ async def lifespan(app: FastAPI):
152
+ # startup
153
+ yield
154
+ # shutdown
155
+
156
+ app = FastAPI(lifespan=lifespan)
157
+ app.include_router(feature_router, prefix=f"/api/{settings.API_VERSION}")
158
+ ```
159
+
160
+ > Use `lifespan=` context manager (FastAPI 0.95+). The deprecated `@app.on_event("startup")` pattern must not be used.
161
+
162
+ ## ORM Model (`infrastructure/orm.py`)
163
+
164
+ ```python
165
+ from __future__ import annotations
166
+
167
+ import uuid
168
+ from datetime import datetime
169
+
170
+ from sqlalchemy import UUID as SA_UUID
171
+ from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, func
172
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
173
+
174
+ from app.core.database import Base
175
+ from modules.feature.domain.entities import FeatureStatus
176
+
177
+
178
+ class FeatureORM(Base):
179
+ __tablename__ = "features"
180
+
181
+ id: Mapped[uuid.UUID] = mapped_column(
182
+ SA_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
183
+ )
184
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
185
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
186
+ status: Mapped[FeatureStatus] = mapped_column(
187
+ Enum(FeatureStatus), nullable=False, default=FeatureStatus.DRAFT
188
+ )
189
+ user_id: Mapped[uuid.UUID] = mapped_column(
190
+ SA_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True
191
+ )
192
+ created_at: Mapped[datetime] = mapped_column(
193
+ DateTime(timezone=True), server_default=func.now(), nullable=False
194
+ )
195
+ deleted_at: Mapped[datetime | None] = mapped_column(
196
+ DateTime(timezone=True), nullable=True
197
+ )
198
+
199
+ user: Mapped["UserORM"] = relationship("UserORM", lazy="noload")
200
+ ```
201
+
202
+ ## Rules
203
+
204
+ - Routers are thin — one line per handler, everything delegated to service
205
+ - `Depends()` factory per module in `dependencies.py`
206
+ - ORM models live in `infrastructure/orm.py`, never in `domain/`
207
+ - Domain entities are plain dataclasses — no SQLAlchemy imports in `domain/`
208
+ - Abstract repository interfaces live in `infrastructure/repositories/base.py`; SQLAlchemy implementations are separate files in the same folder
209
+ - Register all routers in `app/main.py` with versioned prefix
210
+ - Use `server_default=func.now()` for `created_at` — not Python-side `datetime.now()` in ORM defaults
211
+ - Do NOT scaffold `events.py`, `value_objects.py`, or `tasks.py` unless the module actually needs them