From 162f585e3595a1fb8c2c76c6ff5bb64355ef29a6 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 13 Jun 2026 00:25:52 -0700 Subject: [PATCH] chore: remove entire skills package from codebase Deletes the skills source package (src/fastapi_startkit/skills/) and all associated tests (tests/skills/). The skills feature was experimental and is no longer needed; removing it keeps the framework lean and avoids any maintenance burden. Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/skills/__init__.py | 7 - .../skills/adapters/__init__.py | 7 - .../fastapi_startkit/skills/adapters/base.py | 57 ----- .../skills/adapters/claude.py | 91 -------- .../skills/adapters/gemini.py | 110 ---------- .../skills/commands/__init__.py | 5 - .../skills/commands/skills.py | 80 ------- .../src/fastapi_startkit/skills/parser.py | 74 ------- .../src/fastapi_startkit/skills/provider.py | 17 -- .../src/fastapi_startkit/skills/registry.py | 158 -------------- .../.ai/fastapi-startkit/database/SKILL.md | 107 --------- .../.ai/fastapi-startkit/fastapi/SKILL.md | 109 ---------- fastapi_startkit/tests/skills/__init__.py | 0 .../tests/skills/test_adapters.py | 205 ------------------ .../tests/skills/test_commands.py | 161 -------------- fastapi_startkit/tests/skills/test_parser.py | 86 -------- .../tests/skills/test_registry.py | 128 ----------- 17 files changed, 1402 deletions(-) delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/__init__.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/adapters/__init__.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/commands/skills.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/parser.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/provider.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/registry.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/database/SKILL.md delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md delete mode 100644 fastapi_startkit/tests/skills/__init__.py delete mode 100644 fastapi_startkit/tests/skills/test_adapters.py delete mode 100644 fastapi_startkit/tests/skills/test_commands.py delete mode 100644 fastapi_startkit/tests/skills/test_parser.py delete mode 100644 fastapi_startkit/tests/skills/test_registry.py diff --git a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py deleted file mode 100644 index d498c5e5..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .registry import SkillRegistry -from .provider import AISkillProvider - -__all__ = [ - "SkillRegistry", - "AISkillProvider", -] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/__init__.py deleted file mode 100644 index 5c444d2e..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/adapters/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Adapter layer for rendering canonical skills into agent-specific formats.""" - -from .base import BaseAdapter -from .claude import ClaudeAdapter -from .gemini import GeminiAdapter - -__all__ = ["BaseAdapter", "ClaudeAdapter", "GeminiAdapter"] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py deleted file mode 100644 index 9bf92e79..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py +++ /dev/null @@ -1,57 +0,0 @@ -"""BaseAdapter — abstract base class for all skill adapters. - -New adapters (e.g. Codex) only need to subclass :class:`BaseAdapter` and -implement :meth:`render` and :meth:`prune`. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from pathlib import Path -from typing import Callable, Sequence - -from fastapi_startkit.skills.registry import Skill - - -class BaseAdapter(ABC): - """Abstract base for skill adapters. - - Parameters - ---------- - base_path: - Root of the project (where ``.claude/``, ``GEMINI.md``, etc. live). - Defaults to the current working directory. - """ - - #: Short identifier shown in CLI output (e.g. "claude", "gemini"). - name: str = "" - - def __init__(self, base_path: Path | str | None = None) -> None: - self.base_path = Path(base_path) if base_path else Path.cwd() - - # ------------------------------------------------------------------ - # Abstract interface - # ------------------------------------------------------------------ - - @abstractmethod - def render( - self, - skills: Sequence[Skill], - force: bool = False, - confirm: Callable[..., bool] | None = None, - ) -> list[str]: - """Write *skills* to the target format. - - Existing files are preserved unless *force* is true or *confirm* - returns true for the overwrite prompt — mirroring ``provider:publish``. - - Returns a list of human-readable lines describing what was written - (suitable for printing in the ``ai:skills`` command). - """ - - @abstractmethod - def prune(self, skills: Sequence[Skill]) -> list[str]: - """Remove previously-synced skills that are *not* in *skills*. - - Returns a list of human-readable lines describing what was removed. - """ diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py deleted file mode 100644 index 6c1ebe6f..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py +++ /dev/null @@ -1,91 +0,0 @@ -"""ClaudeAdapter — renders skills into ``.claude/skills//SKILL.md``.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable, Sequence - -from fastapi_startkit.skills.registry import Skill -from .base import BaseAdapter - - -class ClaudeAdapter(BaseAdapter): - """Writes canonical skills into Claude Code's skill directory. - - Each skill is rendered as ``.claude/skills//SKILL.md`` with a - YAML front-matter block followed by the original body. Publishing follows - the same rules as ``provider:publish``: a missing file is written, an - existing file is left untouched unless ``force`` is set or the caller - confirms the overwrite. - """ - - name = "claude" - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def render( - self, - skills: Sequence[Skill], - force: bool = False, - confirm: Callable[..., bool] | None = None, - ) -> list[str]: - messages: list[str] = [] - for skill in skills: - dest = self._skill_path(skill.name) - rel = f".claude/skills/{skill.name}/SKILL.md" - content = self._build_content(skill) - - if dest.exists() and not force: - if not self._confirm_overwrite(confirm, rel): - messages.append(f"[claude] Skipped {rel} (already exists)") - continue - verb = "Overwrote" - else: - verb = "Overwrote" if dest.exists() else "Published" - - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(content, encoding="utf-8") - messages.append(f"[claude] {verb} {rel}") - return messages - - @staticmethod - def _confirm_overwrite(confirm: Callable[..., bool] | None, rel: str) -> bool: - """Ask the caller whether to overwrite *rel*; default to no.""" - if confirm is None: - return False - return bool(confirm(f" {rel} already exists. Overwrite?", default=False)) - - def prune(self, skills: Sequence[Skill]) -> list[str]: - """Remove ``.claude/skills//`` dirs not represented in *skills*.""" - messages: list[str] = [] - known_names = {s.name for s in skills} - skills_root = self.base_path / ".claude" / "skills" - if not skills_root.is_dir(): - return messages - - for child in sorted(skills_root.iterdir()): - if child.is_dir() and child.name not in known_names: - import shutil - - shutil.rmtree(child) - messages.append(f"[claude] Pruned .claude/skills/{child.name}/") - return messages - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _skill_path(self, skill_name: str) -> Path: - return self.base_path / ".claude" / "skills" / skill_name / "SKILL.md" - - @staticmethod - def _build_content(skill: Skill) -> str: - """Render the SKILL.md content for *skill*.""" - lines = ["---", f"name: {skill.name}", f"description: {skill.description}", "---"] - if skill.body: - lines.append("") - lines.append(skill.body) - lines.append("") - return "\n".join(lines) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py deleted file mode 100644 index da844aeb..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py +++ /dev/null @@ -1,110 +0,0 @@ -"""GeminiAdapter — renders skills into ``GEMINI.md`` via marker blocks. - -The adapter manages only the region of ``GEMINI.md`` that lies between the -```` and ```` markers. Content -outside those markers is **never** modified, making the adapter safe to use -even when the user has hand-edited the rest of the file. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable, Sequence - -from fastapi_startkit.skills.registry import Skill -from .base import BaseAdapter - -_MARKER_START = "" -_MARKER_END = "" - - -class GeminiAdapter(BaseAdapter): - """Writes canonical skills into ``GEMINI.md`` with HTML comment markers. - - If ``GEMINI.md`` does not exist it is created from scratch. If it exists - the content between the markers is replaced; everything outside is left - unchanged. The write is idempotent. - """ - - name = "gemini" - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def render( - self, - skills: Sequence[Skill], - force: bool = False, - confirm: Callable[..., bool] | None = None, - ) -> list[str]: - # GEMINI.md edits are confined to the skills marker block, so user - # content is never clobbered — force/confirm are accepted for a uniform - # adapter interface but the block is always kept in sync. - gemini_md = self.base_path / "GEMINI.md" - new_section = self._build_section(skills) - changed = self._update_file(gemini_md, new_section) - verb = "Updated" if changed else "Unchanged" - return [f"[gemini] {verb} GEMINI.md ({len(skills)} skill(s))"] - - def prune(self, skills: Sequence[Skill]) -> list[str]: - """For Gemini, pruning just re-renders with the current skill list. - - Since everything lives in a single file within a marked block, - rendering the new (shorter) list is equivalent to pruning. - """ - return self.render(skills) - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _build_section(self, skills: Sequence[Skill]) -> str: - """Return the full marker block to inject into GEMINI.md.""" - parts = [_MARKER_START] - for skill in skills: - parts.append(f"\n## {skill.name}\n") - if skill.description: - parts.append(f"{skill.description}\n") - if skill.body: - parts.append(f"\n{skill.body}\n") - parts.append(_MARKER_END) - return "\n".join(parts) - - def _update_file(self, path: Path, section: str) -> bool: - """Inject *section* into *path*, preserving content outside markers. - - Returns *True* when the file was (re)written, *False* when unchanged. - """ - if path.exists(): - original = path.read_text(encoding="utf-8") - else: - original = "" - - new_content = self._splice(original, section) - - if original == new_content: - return False - - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(new_content, encoding="utf-8") - return True - - @staticmethod - def _splice(original: str, section: str) -> str: - """Replace the skills block inside *original* with *section*. - - If the markers do not exist yet the section is appended to the file - (separated by a blank line). - """ - start_idx = original.find(_MARKER_START) - end_idx = original.find(_MARKER_END) - - if start_idx != -1 and end_idx != -1 and end_idx > start_idx: - before = original[:start_idx] - after = original[end_idx + len(_MARKER_END) :] - return before + section + after - else: - # No markers yet — append - separator = "\n\n" if original and not original.endswith("\n\n") else "" - return original + separator + section + "\n" diff --git a/fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py deleted file mode 100644 index 13b7e335..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Cleo commands for the skills module.""" - -from .skills import SkillsCommand - -__all__ = ["SkillsCommand"] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/commands/skills.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/skills.py deleted file mode 100644 index 66d4d210..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/commands/skills.py +++ /dev/null @@ -1,80 +0,0 @@ -"""ai:skills — list or sync provider-declared AI skills. - -Examples:: - - artisan ai:skills --list - artisan ai:skills --sync - artisan ai:skills --sync --target=claude --prune -""" - -from __future__ import annotations - -from cleo.helpers import option - -from fastapi_startkit.console import Command - - -class SkillsCommand(Command): - name = "ai:skills" - description = "List or sync provider-declared AI skills." - - options = [ - option("list", "l", flag=True, description="List providers that declare skills (default)."), - option("sync", "s", flag=True, description="Publish skills and sync them to AI agents."), - option( - "target", - "t", - flag=False, - description="Sync target: claude | gemini | all. Prompts if omitted.", - ), - option( - "prune", - None, - flag=True, - description="Remove skill files no longer declared by any provider.", - ), - option( - "force", - "f", - flag=True, - description="Overwrite existing files without prompting.", - ), - ] - - def handle(self) -> int: - from fastapi_startkit.skills.registry import SkillRegistry - - registry: SkillRegistry = self.container.make("ai_skill.skills.registry") - - # --target / --prune only make sense when syncing, so they imply --sync. - if self.option("sync") or self.option("prune") or self.option("target"): - return self._sync(registry) - - return self._list(registry) - - def _list(self, registry) -> int: - providers = registry.get_providers() - - if providers.is_empty(): - self.line("No skills found in any registered provider.") - return 0 - - self.info("Skill(s) found for the following provider(s):") - for index, provider_key in enumerate(providers, start=1): - self.info(f"{index}. {provider_key}") - return 0 - - def _sync(self, registry) -> int: - choices = ["all", "claude", "gemini"] - target = self.option("target") - if not target: - answer = self.choice("Which agent do you want to publish for?", choices, default=0) - # Interactive returns the value; non-interactive returns the default index. - target = choices[answer] if isinstance(answer, int) else answer - target = target.lower() - prune = bool(self.option("prune")) - force = bool(self.option("force")) - - for message in registry.publish(target=target, prune=prune, force=force, confirm=self.confirm): - self.line(f" {message}") - return 0 diff --git a/fastapi_startkit/src/fastapi_startkit/skills/parser.py b/fastapi_startkit/src/fastapi_startkit/skills/parser.py deleted file mode 100644 index e65280e8..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/parser.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path - - -@dataclass -class Skill: - name: str - description: str - path: Path - provider_key: str = "fastapi-startkit" - body: str = field(default="", repr=False) - metadata: dict = field(default_factory=dict, repr=False) - - -class SkillParser: - """Parses SKILL.md files into :class:`Skill` objects.""" - - def parse(self, path: Path, provider_key: str = "fastapi-startkit") -> "Skill | None": - """Read and parse *path* into a Skill, or ``None`` if it can't be read - or has no name.""" - try: - text = path.read_text(encoding="utf-8") - except OSError: - return None - - meta, body = self.parse_frontmatter(text) - name = (meta.get("name") or path.parent.name or "").strip() - if not name: - return None - - description = (meta.get("description") or "").strip() - extra = {k: v for k, v in meta.items() if k not in ("name", "description")} - return Skill( - name=name, - description=description, - path=path, - provider_key=provider_key, - body=body.strip(), - metadata=extra, - ) - - @staticmethod - def parse_frontmatter(text: str) -> tuple[dict, str]: - """Split YAML front-matter from the body. Returns (meta_dict, body_str).""" - lines = text.splitlines(keepends=True) - if not lines or lines[0].strip() != "---": - return {}, text - - end_idx = None - for i, line in enumerate(lines[1:], start=1): - if line.strip() == "---": - end_idx = i - break - - if end_idx is None: - return {}, text - - fm_text = "".join(lines[1:end_idx]) - body = "".join(lines[end_idx + 1 :]) - - try: - import yaml - - meta = yaml.safe_load(fm_text) or {} - except ModuleNotFoundError: - meta = {} - for line in lines[1:end_idx]: - if ":" in line: - k, _, v = line.partition(":") - meta[k.strip()] = v.strip() - - return meta, body diff --git a/fastapi_startkit/src/fastapi_startkit/skills/provider.py b/fastapi_startkit/src/fastapi_startkit/skills/provider.py deleted file mode 100644 index ab65b604..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/provider.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from fastapi_startkit.providers import Provider - - -class AISkillProvider(Provider): - provider_key = "ai_skill" - - def register(self) -> None: - from fastapi_startkit.skills.registry import SkillRegistry - - self.app.bind(f"{self.provider_key}.skills.registry", SkillRegistry(self.app)) - - def boot(self) -> None: - from fastapi_startkit.skills.commands import SkillsCommand - - self.commands([SkillsCommand]) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/registry.py deleted file mode 100644 index 6d60c5a1..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/registry.py +++ /dev/null @@ -1,158 +0,0 @@ -"""SkillRegistry — maps providers to their skill files and publishes them. - -Each provider key in :attr:`SkillRegistry.skills` points at one or more -``SKILL.md`` destinations under ``.ai/fastapi-startkit/``. The framework ships a -matching stub for each at ``skills/stubs/``. - -* ``get_providers()`` — registered providers that declare skills (used by list). -* ``publish()`` — copy stubs into the project then render to AI agents. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Callable, TYPE_CHECKING - -from fastapi_startkit.skills.parser import Skill, SkillParser -from fastapi_startkit.support.collection import collect - -if TYPE_CHECKING: - from fastapi_startkit.application import Application - from fastapi_startkit.support.collection import Collection - -__all__ = ["Skill", "SkillRegistry", "STUBS_BASE_PATH"] - -#: Source location of framework-shipped skill stubs, organised by provider key: -#: stubs/.ai/fastapi-startkit//SKILL.md -STUBS_BASE_PATH = Path(__file__).resolve().parent / "stubs" / ".ai" / "fastapi-startkit" - - -class SkillRegistry: - skills = { - "fastapi": [ - ".ai/fastapi-startkit/fastapi/SKILL.md", - ], - "database": [ - ".ai/fastapi-startkit/database/SKILL.md", - ], - } - - def __init__(self, app: "Application") -> None: - self.application = app - self.parser = SkillParser() - - @property - def base_path(self) -> Path: - return Path(self.application.base_path) - - @property - def stubs_root(self) -> Path: - return STUBS_BASE_PATH.parent.parent # .../skills/stubs - - def get_providers(self) -> "Collection": - """Registered providers that declare skills (keyed by provider key).""" - return ( - collect(self.application.providers) - .map(lambda provider: provider.provider_key) - .filter(lambda key: key in self.skills) - ) - - def discover(self) -> list[Skill]: - """Load a Skill for every declared file of every registered provider. - - Prefers the project copy under ``.ai/`` (so user edits flow through) and - falls back to the bundled stub when the project hasn't published it yet. - """ - skills: list[Skill] = [] - for provider_key in self.get_providers(): - for dest in self.skills.get(provider_key, []): - source = self.base_path / dest - if not source.is_file(): - source = self.stubs_root / dest - skill = self.parser.parse(source, provider_key) - if skill is not None: - skills.append(skill) - - return skills - - def publish( - self, - target: str = "all", - prune: bool = False, - force: bool = False, - confirm: Callable[..., bool] | None = None, - ) -> list[str]: - """Copy stubs into the project, then render skills to the AI agent(s). - - Existing destination files are preserved unless *force* is set or - *confirm* approves the overwrite — matching ``provider:publish``. - - Returns human-readable log lines for the calling command to print. - """ - messages = self._publish_stubs(force=force, confirm=confirm) - - skills = self.discover() - if not skills: - messages.append("No skills found in any registered provider.") - return messages - - adapters = self._resolve_adapters(target) - if not adapters: - messages.append(f"Unknown target '{target}'. Use: claude, gemini, all.") - return messages - - messages.append(f"Syncing {len(skills)} skill(s) to: {target}…") - for adapter in adapters: - messages.extend(adapter.render(skills, force=force, confirm=confirm)) - if prune: - messages.extend(adapter.prune(skills)) - return messages - - def _publish_stubs( - self, - force: bool = False, - confirm: Callable[..., bool] | None = None, - ) -> list[str]: - """Copy bundled stubs into the project's ``.ai/``. - - A missing file is always written. An existing file is left untouched - (preserving user edits) unless *force* is set or *confirm* approves the - overwrite — so framework stub updates can be pulled in deliberately. - """ - import shutil - - messages: list[str] = [] - for provider_key in self.get_providers(): - for dest in self.skills.get(provider_key, []): - source = self.stubs_root / dest - if not source.is_file(): - continue - - dest_path = self.base_path / dest - existed = dest_path.exists() - if existed and not force: - if confirm is None or not confirm( - f" {dest} already exists. Overwrite?", default=False - ): - messages.append(f"Skipped {dest} (already exists)") - continue - - dest_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, dest_path) - verb = "Overwrote" if existed else "Published" - messages.append(f"{verb} {dest}") - return messages - - def _resolve_adapters(self, target: str) -> list: - from fastapi_startkit.skills.adapters import ClaudeAdapter, GeminiAdapter - - all_adapters = { - "claude": ClaudeAdapter, - "gemini": GeminiAdapter, - } - - if target == "all": - return [cls(self.base_path) for cls in all_adapters.values()] - - cls = all_adapters.get(target) - return [cls(self.base_path)] if cls else [] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/database/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/database/SKILL.md deleted file mode 100644 index da7503f5..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/database/SKILL.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: database -description: Async ORM for fastapi-startkit — models, relationships, migrations, seeders, and the query builder. ---- - -# Database & ORM - -fastapi-startkit ships an async-first Masonite ORM built on SQLAlchemy. Every -database operation is `async`/`await`. Table names are auto-pluralized from the -model class via `inflection`, and `created_at`/`updated_at` are managed as -`pendulum` Carbon objects. - -## Models - -```python -# app/models/user.py -from fastapi_startkit.masoniteorm import Model -from fastapi_startkit.masoniteorm import HasOne, HasMany, BelongsTo, BelongsToMany - - -class User(Model): - __table__ = "users" # optional — inferred as "users" otherwise - - name: str - email: str - role: str - - profile = HasOne("Profile") - courses = BelongsToMany( - "Course", - local_foreign_key="user_id", - other_foreign_key="course_id", - table="course_user", - with_timestamps=True, - with_fields=["progress", "completed_at"], - ) -``` - -Relationships available: `HasOne`, `HasMany`, `BelongsTo`, `BelongsToMany`, -`HasManyThrough`, and `MorphMany`. Reference the related model by class name as a -string to avoid circular imports. - -## Query builder - -All queries are awaited: - -```python -from app.models.user import User - -user = await User.find(1) -user = await User.where("email", "a@b.com").first() -users = await User.where("role", "admin").get() -user = await User.create({"name": "Ada", "email": "ada@example.com"}) -await user.update({"role": "admin"}) -await user.delete() -``` - -Eager-load relationships to avoid N+1 queries: - -```python -courses = await Course.with_("category", "lessons").get() -``` - -## Migrations - -```python -# databases/migrations/2024_01_01_000000_create_lessons_table.py -from fastapi_startkit.masoniteorm import Migration - - -class CreateLessonsTable(Migration): - async def up(self): - async with await self.schema.create("lessons") as table: - table.increments("id") - table.integer("course_id").unsigned() - table.foreign("course_id").references("id").on("courses").on_delete("cascade") - table.string("title") - table.timestamps() - - async def down(self): - await self.schema.drop("lessons") -``` - -## Seeders - -```python -# databases/seeders/course_seeder.py -from fastapi_startkit.masoniteorm.seeds import Seeder -from app.models.course import Course - - -class CourseSeeder(Seeder): - async def run(self): - await Course.create({"title": "Python for Beginners", "price": 0}) -``` - -## Console commands - -```bash -uv run artisan db:make:model User # scaffold a model -uv run artisan db:make:migration create_users_table -uv run artisan db:migrate # run pending migrations -uv run artisan db:migrate:rollback # roll back the last batch -uv run artisan db:migrate:fresh # drop all + re-migrate -uv run artisan db:migrate:status # show migration state -uv run artisan db:seed # run seeders -``` diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md deleted file mode 100644 index ace0a63b..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -name: fastapi-startkit -description: Routing, controllers, ORM, requests, resources, and action pattern for fastapi-startkit applications. ---- - -# Fastapi's Routing - -### Fastapi Startkit's Router -```python -# routes/web.py -from fastapi_startkit.fastapi import Router - -router = Router() -``` - -and use the crud resources, for example -```python -router.post("/users", users_controller.store) -router.put("/users/{user_id}", users_controller.update) -router.patch("/users/{user_id}", users_controller.patch) -router.delete("/users", users_controller.destroy) -``` - -the controller will look like -```python -# app/http/controllers/users_controller.py -async def index(request: Request): - pass - -async def show(user_id: int): - pass - -async def store(data: UserSchema): - pass - -async def update(user_id: int, data: UserSchema): - pass - -async def destroy(user_id: int): - pass -``` - -or use the resource function as: -```python -router.resource("users", users_controller, excepts=['create', 'edit']) -``` - -## ORM -```python -# app/models/user.py -from fastapi_startkit.masoniteorm import Model - -class User(Model): - id: int - name: str - email: str - metadata: dict -``` - -and use the orm as: -```python -# app/http/controllers/users_controller.py -from app.models import User - -async def store(request: UserStoreRequest): - user = User.create(request.model_dump()) - ... -``` - -the `UserStoreRequest` will look like: -```python -# app/http/requests/user_store_request.py -from pydantic import BaseModel - -class UserStoreRequest(BaseModel): - name: str -``` - -and use JsonApiResource to return JSON response from the controller: -```python -from fastapi_startkit.jsonapi import JsonResource - -# app/http/controllers/users_controller.py -from app.models import User - -async def store(request: UserStoreRequest): - user = User.create(request.model_dump()) - return JsonResource(user) -``` - -## Architecture - -use the action pattern to write complex logic. -```python -# app/actions/user_actions.py -from app.models import User - -class UserStoreAction: - def __init__(self, request: UserStoreRequest): - self.request = request - - @staticmethod - def prepare(request: UserStoreRequest) -> 'UserStoreAction': - return UserStoreAction(request) - - def handle(self) -> JsonResource[User]: - user = User.create(self.request.model_dump()) - return JsonResource(user) -``` diff --git a/fastapi_startkit/tests/skills/__init__.py b/fastapi_startkit/tests/skills/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/fastapi_startkit/tests/skills/test_adapters.py b/fastapi_startkit/tests/skills/test_adapters.py deleted file mode 100644 index 20698c2d..00000000 --- a/fastapi_startkit/tests/skills/test_adapters.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Tests for ClaudeAdapter and GeminiAdapter (task #140).""" - -from __future__ import annotations - -from pathlib import Path - -from fastapi_startkit.skills.registry import Skill -from fastapi_startkit.skills.adapters.claude import ClaudeAdapter -from fastapi_startkit.skills.adapters.gemini import GeminiAdapter, _MARKER_START, _MARKER_END - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def make_skill(name: str, description: str = "A test skill.", body: str = "") -> Skill: - return Skill(name=name, description=description, path=Path("/dev/null"), provider_key="test", body=body) - - -# =========================================================================== -# ClaudeAdapter -# =========================================================================== - - -class TestClaudeAdapter: - def test_render_creates_skill_file(self, tmp_path): - adapter = ClaudeAdapter(base_path=tmp_path) - skills = [make_skill("my-skill", "Does stuff")] - - messages = adapter.render(skills) - - skill_file = tmp_path / ".claude" / "skills" / "my-skill" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - assert "name: my-skill" in content - assert "description: Does stuff" in content - assert any("Published" in m for m in messages) - - def test_render_skips_existing_without_force_or_confirm(self, tmp_path): - adapter = ClaudeAdapter(base_path=tmp_path) - skills = [make_skill("my-skill", "Old description")] - - adapter.render(skills) - # Second render: file exists, no force and no confirm callback → skip. - messages = adapter.render([make_skill("my-skill", "New description")]) - - skill_file = tmp_path / ".claude" / "skills" / "my-skill" / "SKILL.md" - assert "Old description" in skill_file.read_text() - assert any("Skipped" in m for m in messages) - - def test_render_force_overwrites_existing(self, tmp_path): - adapter = ClaudeAdapter(base_path=tmp_path) - adapter.render([make_skill("my-skill", "Old description")]) - messages = adapter.render([make_skill("my-skill", "New description")], force=True) - - skill_file = tmp_path / ".claude" / "skills" / "my-skill" / "SKILL.md" - assert "New description" in skill_file.read_text() - assert any("Overwrote" in m for m in messages) - - def test_render_confirm_yes_overwrites_existing(self, tmp_path): - adapter = ClaudeAdapter(base_path=tmp_path) - adapter.render([make_skill("my-skill", "Old description")]) - messages = adapter.render( - [make_skill("my-skill", "New description")], - confirm=lambda *a, **kw: True, - ) - - skill_file = tmp_path / ".claude" / "skills" / "my-skill" / "SKILL.md" - assert "New description" in skill_file.read_text() - assert any("Overwrote" in m for m in messages) - - def test_render_confirm_no_skips_existing(self, tmp_path): - adapter = ClaudeAdapter(base_path=tmp_path) - adapter.render([make_skill("my-skill", "Old description")]) - messages = adapter.render( - [make_skill("my-skill", "New description")], - confirm=lambda *a, **kw: False, - ) - - skill_file = tmp_path / ".claude" / "skills" / "my-skill" / "SKILL.md" - assert "Old description" in skill_file.read_text() - assert any("Skipped" in m for m in messages) - - def test_render_includes_body(self, tmp_path): - adapter = ClaudeAdapter(base_path=tmp_path) - skills = [make_skill("qs", "Query skill", body="Use `Model.where(...)`")] - adapter.render(skills) - - skill_file = tmp_path / ".claude" / "skills" / "qs" / "SKILL.md" - content = skill_file.read_text() - assert "Use `Model.where(...)`" in content - - def test_prune_removes_unlisted_skills(self, tmp_path): - adapter = ClaudeAdapter(base_path=tmp_path) - # Pre-create two skills - adapter.render([make_skill("keep-me"), make_skill("remove-me")]) - - # Render with only one skill, prune the other - messages = adapter.prune([make_skill("keep-me")]) - - assert (tmp_path / ".claude" / "skills" / "keep-me").exists() - assert not (tmp_path / ".claude" / "skills" / "remove-me").exists() - assert any("Pruned" in m for m in messages) - - def test_prune_noop_when_no_skills_dir(self, tmp_path): - adapter = ClaudeAdapter(base_path=tmp_path) - messages = adapter.prune([]) - assert messages == [] - - def test_render_multiple_skills(self, tmp_path): - adapter = ClaudeAdapter(base_path=tmp_path) - skills = [make_skill("skill-a"), make_skill("skill-b"), make_skill("skill-c")] - messages = adapter.render(skills) - - assert len(messages) == 3 - for name in ["skill-a", "skill-b", "skill-c"]: - assert (tmp_path / ".claude" / "skills" / name / "SKILL.md").exists() - - -# =========================================================================== -# GeminiAdapter -# =========================================================================== - - -class TestGeminiAdapter: - def test_render_creates_gemini_md(self, tmp_path): - adapter = GeminiAdapter(base_path=tmp_path) - skills = [make_skill("my-skill", "Does stuff")] - - messages = adapter.render(skills) - - gemini_md = tmp_path / "GEMINI.md" - assert gemini_md.exists() - content = gemini_md.read_text() - assert _MARKER_START in content - assert _MARKER_END in content - assert "my-skill" in content - assert "Does stuff" in content - assert any("Updated" in m for m in messages) - - def test_render_idempotent(self, tmp_path): - adapter = GeminiAdapter(base_path=tmp_path) - skills = [make_skill("my-skill")] - - adapter.render(skills) - messages = adapter.render(skills) - assert any("Unchanged" in m for m in messages) - - def test_render_preserves_content_outside_markers(self, tmp_path): - gemini_md = tmp_path / "GEMINI.md" - gemini_md.write_text( - "# My Project\n\nSome user content.\n\n" + _MARKER_START + "\nold skills\n" + _MARKER_END + "\n\n" - "## Extra Section\n\nUser notes.\n" - ) - - adapter = GeminiAdapter(base_path=tmp_path) - adapter.render([make_skill("new-skill")]) - - content = gemini_md.read_text() - assert "My Project" in content - assert "Some user content." in content - assert "Extra Section" in content - assert "User notes." in content - assert "new-skill" in content - - def test_render_appends_if_no_markers(self, tmp_path): - gemini_md = tmp_path / "GEMINI.md" - gemini_md.write_text("# Existing content\n\nUser authored.\n") - - adapter = GeminiAdapter(base_path=tmp_path) - adapter.render([make_skill("appended-skill")]) - - content = gemini_md.read_text() - assert "Existing content" in content - assert "appended-skill" in content - assert _MARKER_START in content - assert _MARKER_END in content - - def test_render_multiple_skills(self, tmp_path): - adapter = GeminiAdapter(base_path=tmp_path) - skills = [make_skill("skill-a", "First skill"), make_skill("skill-b", "Second skill")] - adapter.render(skills) - - content = (tmp_path / "GEMINI.md").read_text() - assert "skill-a" in content - assert "skill-b" in content - - def test_prune_rerenders_with_fewer_skills(self, tmp_path): - adapter = GeminiAdapter(base_path=tmp_path) - adapter.render([make_skill("keep"), make_skill("drop")]) - adapter.prune([make_skill("keep")]) - - content = (tmp_path / "GEMINI.md").read_text() - assert "keep" in content - assert "drop" not in content - - def test_render_updates_changed_description(self, tmp_path): - adapter = GeminiAdapter(base_path=tmp_path) - adapter.render([make_skill("s", "Old desc")]) - adapter.render([make_skill("s", "New desc")]) - - content = (tmp_path / "GEMINI.md").read_text() - assert "New desc" in content - assert "Old desc" not in content diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py deleted file mode 100644 index 6bfd104d..00000000 --- a/fastapi_startkit/tests/skills/test_commands.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Tests for the ai:skills command (list + sync). - -The skills module is provider-driven: a provider declares skills by having a -``provider_key`` that appears in :attr:`SkillRegistry.skills`. The real -``FastAPIProvider`` declares the ``fastapi`` skill, so the tests register it -(rather than a stub provider). ``AISkillProvider`` binds the registry the -command resolves. - -The command is driven through Cleo's :class:`CommandTester`, so option parsing -and IO go through the real machinery. Tests run non-interactively, so an omitted -``--target`` resolves to the prompt's default ("all") instead of blocking. -""" - -from __future__ import annotations - -import pytest -from cleo.testers.command_tester import CommandTester - -from fastapi_startkit.application import Application -from fastapi_startkit.fastapi.providers.fastapi_provider import FastAPIProvider -from fastapi_startkit.skills import AISkillProvider -from fastapi_startkit.skills.commands import SkillsCommand - -#: Claude skill dir for the fastapi skill (from the stub's front-matter name). -FASTAPI_SKILL = "fastapi-startkit" - - -@pytest.fixture(autouse=True) -def restore_container(): - from fastapi_startkit.container.container import Container - - original = Container._instance - yield - Container._instance = original - - -@pytest.fixture -def app(tmp_path): - """App with the real skill-declaring FastAPIProvider and a bound registry.""" - return Application( - base_path=tmp_path, - env="testing", - providers=[AISkillProvider, FastAPIProvider], - ) - - -@pytest.fixture -def empty_app(tmp_path): - """App with no skill-declaring providers (registry still bound).""" - return Application(base_path=tmp_path, env="testing", providers=[AISkillProvider]) - - -def run(app, argv: str = "") -> CommandTester: - """Execute ai:skills with *argv* against *app* and return the tester.""" - command = SkillsCommand() - command.set_container(app) - tester = CommandTester(command) - tester.execute(argv, interactive=False) - return tester - - -# =========================================================================== -# ai:skills --list -# =========================================================================== - - -class TestSkillsListCommand: - def test_list_shows_declaring_providers(self, app): - tester = run(app, "--list") - assert tester.status_code == 0 - assert "fastapi" in tester.io.fetch_output() - - def test_list_is_default_when_no_flags(self, app): - tester = run(app) - assert tester.status_code == 0 - assert "fastapi" in tester.io.fetch_output() - - def test_list_no_skills_shows_message(self, empty_app): - tester = run(empty_app, "--list") - assert tester.status_code == 0 - assert "No skills" in tester.io.fetch_output() - - def test_command_name(self): - assert SkillsCommand().name == "ai:skills" - - -# =========================================================================== -# ai:skills --sync -# =========================================================================== - - -class TestSkillsSyncCommand: - def test_sync_claude_publishes_stub_and_skill_file(self, app, tmp_path): - tester = run(app, "--sync --target=claude") - assert tester.status_code == 0 - # Stub is copied into the project's .ai/ tree. - assert (tmp_path / ".ai" / "fastapi-startkit" / "fastapi" / "SKILL.md").exists() - # ClaudeAdapter writes one dir per skill name (from stub front-matter). - assert (tmp_path / ".claude" / "skills" / FASTAPI_SKILL / "SKILL.md").exists() - assert not (tmp_path / "GEMINI.md").exists() - - def test_sync_gemini_only(self, app, tmp_path): - tester = run(app, "--sync --target=gemini") - assert tester.status_code == 0 - assert (tmp_path / "GEMINI.md").exists() - assert not (tmp_path / ".claude").exists() - - def test_sync_all_writes_both(self, app, tmp_path): - tester = run(app, "--sync --target=all") - assert tester.status_code == 0 - assert (tmp_path / ".claude" / "skills" / FASTAPI_SKILL / "SKILL.md").exists() - assert (tmp_path / "GEMINI.md").exists() - - def test_omitted_target_defaults_to_all(self, app, tmp_path): - # Non-interactive, so the target prompt resolves to its default ("all"). - tester = run(app, "--sync") - assert tester.status_code == 0 - assert (tmp_path / ".claude" / "skills" / FASTAPI_SKILL / "SKILL.md").exists() - assert (tmp_path / "GEMINI.md").exists() - - def test_prune_implies_sync(self, app, tmp_path): - # --prune alone (no --sync) still triggers a sync. - tester = run(app, "--prune") - assert tester.status_code == 0 - assert (tmp_path / ".claude" / "skills" / FASTAPI_SKILL / "SKILL.md").exists() - - def test_target_flag_implies_sync(self, app, tmp_path): - tester = run(app, "--target=claude") - assert tester.status_code == 0 - assert (tmp_path / ".claude" / "skills" / FASTAPI_SKILL / "SKILL.md").exists() - - def test_sync_unknown_target_reports_error(self, app): - tester = run(app, "--sync --target=codex") - # Unknown target is reported as a message, not a non-zero exit. - assert tester.status_code == 0 - assert "Unknown target" in tester.io.fetch_output() - - def test_sync_no_skills_exits_gracefully(self, empty_app): - tester = run(empty_app, "--sync --target=claude") - assert tester.status_code == 0 - assert "No skills" in tester.io.fetch_output() - - def test_sync_prune_removes_stale_skill(self, app, tmp_path): - # Pre-create a synced skill that no provider declares anymore. - stale = tmp_path / ".claude" / "skills" / "old-skill" - stale.mkdir(parents=True) - (stale / "SKILL.md").write_text("stale") - - run(app, "--sync --target=claude --prune") - - assert not stale.exists() - assert (tmp_path / ".claude" / "skills" / FASTAPI_SKILL / "SKILL.md").exists() - - def test_sync_force_overwrites_existing_stub(self, app, tmp_path): - dest = tmp_path / ".ai" / "fastapi-startkit" / "fastapi" / "SKILL.md" - dest.parent.mkdir(parents=True) - dest.write_text("user edited stub") - - run(app, "--sync --target=claude --force") - - assert "user edited stub" not in dest.read_text() diff --git a/fastapi_startkit/tests/skills/test_parser.py b/fastapi_startkit/tests/skills/test_parser.py deleted file mode 100644 index c62f5c4c..00000000 --- a/fastapi_startkit/tests/skills/test_parser.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Tests for SkillParser — front-matter parsing and SKILL.md → Skill loading.""" - -from __future__ import annotations - -from pathlib import Path - -from fastapi_startkit.skills.parser import Skill, SkillParser - - -def _write(tmp_path: Path, rel: str, text: str) -> Path: - path = tmp_path / rel - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - return path - - -# --------------------------------------------------------------------------- -# parse_frontmatter -# --------------------------------------------------------------------------- - - -def test_parse_frontmatter_basic(): - text = "---\nname: fastapi\ndescription: Apply for FastAPI code.\n---\nBody content here.\n" - meta, body = SkillParser.parse_frontmatter(text) - assert meta["name"] == "fastapi" - assert meta["description"] == "Apply for FastAPI code." - assert "Body content here." in body - - -def test_parse_frontmatter_missing_fence(): - text = "No front-matter here." - meta, body = SkillParser.parse_frontmatter(text) - assert meta == {} - assert body == text - - -def test_parse_frontmatter_unclosed_fence(): - meta, body = SkillParser.parse_frontmatter("---\nname: oops\n") - assert meta == {} - - -# --------------------------------------------------------------------------- -# parse -# --------------------------------------------------------------------------- - - -def test_parse_returns_skill_with_fields(tmp_path): - path = _write( - tmp_path, - "SKILL.md", - "---\nname: routing\ndescription: Routing helpers.\n---\nUse `Router`.\n", - ) - skill = SkillParser().parse(path, provider_key="fastapi") - assert isinstance(skill, Skill) - assert skill.name == "routing" - assert skill.description == "Routing helpers." - assert "Use `Router`." in skill.body - assert skill.provider_key == "fastapi" - assert skill.path == path - - -def test_parse_falls_back_to_parent_dir_name(tmp_path): - path = _write(tmp_path, "fallback-skill/SKILL.md", "No front-matter.") - skill = SkillParser().parse(path) - assert skill is not None - assert skill.name == "fallback-skill" - - -def test_parse_returns_none_for_missing_file(tmp_path): - assert SkillParser().parse(tmp_path / "does-not-exist.md") is None - - -def test_parse_captures_extra_metadata(tmp_path): - path = _write( - tmp_path, - "SKILL.md", - "---\nname: s\ndescription: d\nversion: 2\n---\nBody.\n", - ) - skill = SkillParser().parse(path) - assert skill.metadata == {"version": 2} - - -def test_parse_default_provider_key(tmp_path): - path = _write(tmp_path, "SKILL.md", "---\nname: s\ndescription: d\n---\n") - skill = SkillParser().parse(path) - assert skill.provider_key == "fastapi-startkit" diff --git a/fastapi_startkit/tests/skills/test_registry.py b/fastapi_startkit/tests/skills/test_registry.py deleted file mode 100644 index 9e935afa..00000000 --- a/fastapi_startkit/tests/skills/test_registry.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Tests for the provider-driven SkillRegistry. - -A provider "declares" skills when its ``provider_key`` is a key in -:attr:`SkillRegistry.skills`. The real ``FastAPIProvider`` declares the -``fastapi`` skill; ``AISkillProvider`` (key ``ai_skill``) does not, so it -exercises the registry's filtering. The framework ships a matching ``SKILL.md`` -stub per declared destination, and :meth:`SkillRegistry.discover` prefers a -project copy under ``.ai/`` over the bundled stub. -""" - -from __future__ import annotations - -import pytest - -from fastapi_startkit.application import Application -from fastapi_startkit.container.container import Container -from fastapi_startkit.fastapi.providers.fastapi_provider import FastAPIProvider -from fastapi_startkit.skills import AISkillProvider -from fastapi_startkit.skills.registry import SkillRegistry, STUBS_BASE_PATH - -#: Claude skill dir for the fastapi skill (from the stub's front-matter name). -FASTAPI_SKILL = "fastapi-startkit" - - -@pytest.fixture(autouse=True) -def restore_container(): - original = Container._instance - yield - Container._instance = original - - -@pytest.fixture -def app(tmp_path): - """App with a skill-declaring provider (fastapi) and a non-declaring one.""" - return Application( - base_path=tmp_path, - env="testing", - providers=[AISkillProvider, FastAPIProvider], - ) - - -@pytest.fixture -def empty_app(tmp_path): - return Application(base_path=tmp_path, env="testing") - - -# --------------------------------------------------------------------------- -# get_providers -# --------------------------------------------------------------------------- - - -def test_get_providers_returns_only_declaring_keys(app): - # AISkillProvider (ai_skill) is registered but not declared, so it's filtered. - assert set(SkillRegistry(app).get_providers()) == {"fastapi"} - - -def test_get_providers_empty_when_none_declare(empty_app): - assert list(SkillRegistry(empty_app).get_providers()) == [] - - -# --------------------------------------------------------------------------- -# discover -# --------------------------------------------------------------------------- - - -def test_discover_loads_skill_from_bundled_stub(app): - skills = SkillRegistry(app).discover() - # Name comes from the stub's front-matter; provider_key from the declarer. - assert {s.name for s in skills} == {FASTAPI_SKILL} - assert {s.provider_key for s in skills} == {"fastapi"} - - -def test_discover_empty_when_no_declaring_providers(empty_app): - assert SkillRegistry(empty_app).discover() == [] - - -def test_discover_prefers_project_copy_over_stub(app, tmp_path): - # A user-edited project copy shadows the bundled stub. - dest = tmp_path / ".ai" / "fastapi-startkit" / "fastapi" / "SKILL.md" - dest.parent.mkdir(parents=True) - dest.write_text("---\nname: my-fastapi\ndescription: edited\n---\nbody\n") - - skills = SkillRegistry(app).discover() - assert {s.name for s in skills} == {"my-fastapi"} - assert skills[0].path == dest - - -# --------------------------------------------------------------------------- -# publish -# --------------------------------------------------------------------------- - - -def test_publish_writes_stub_and_renders_claude(app, tmp_path): - messages = SkillRegistry(app).publish(target="claude") - assert (tmp_path / ".ai" / "fastapi-startkit" / "fastapi" / "SKILL.md").exists() - assert (tmp_path / ".claude" / "skills" / FASTAPI_SKILL / "SKILL.md").exists() - assert any("Syncing" in m for m in messages) - - -def test_publish_no_skills_returns_message(empty_app): - messages = SkillRegistry(empty_app).publish(target="claude") - assert any("No skills found" in m for m in messages) - - -def test_publish_unknown_target_returns_message(app): - messages = SkillRegistry(app).publish(target="codex") - assert any("Unknown target" in m for m in messages) - - -def test_publish_all_writes_both(app, tmp_path): - SkillRegistry(app).publish(target="all") - assert (tmp_path / ".claude" / "skills" / FASTAPI_SKILL / "SKILL.md").exists() - assert (tmp_path / "GEMINI.md").exists() - - -# --------------------------------------------------------------------------- -# paths -# --------------------------------------------------------------------------- - - -def test_base_path_property(app, tmp_path): - assert SkillRegistry(app).base_path == tmp_path - - -def test_stubs_root_contains_bundled_stubs(app): - root = SkillRegistry(app).stubs_root - assert (root / ".ai" / "fastapi-startkit" / "fastapi" / "SKILL.md").exists() - assert STUBS_BASE_PATH.is_dir()