From 786a6205466b72961d5c8c7a18c350886c628e78 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 23:22:07 -0700 Subject: [PATCH 1/2] refactor(skills): provider-driven ai:skills command Replace the directory-scanning skills/rules modules with a single provider-driven `ai:skills` command. Skills are now declared by a provider whose `provider_key` appears in `SkillRegistry.skills`, with a bundled `SKILL.md` stub per declared destination. - Remove the entire `skills/rules/` subtree and the separate `skills:sync`/`skills:list`/`rules:*` commands. - Add `SkillsCommand` (`ai:skills --list/--sync --target --prune --force`). - Extract front-matter parsing into `SkillParser` (`parser.py`). - Rework `SkillRegistry` around `get_providers()`/`discover()`/`publish()`, preferring the project's `.ai/` copy over the bundled stub. - Rename the fastapi stub dir to match its provider key. - Rewrite skills tests against the new API (registering the real FastAPIProvider/DatabaseProvider) and add `test_parser.py`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/fastapi_startkit/skills/__init__.py | 23 +- .../fastapi_startkit/skills/adapters/base.py | 14 +- .../skills/adapters/claude.py | 53 ++-- .../skills/adapters/gemini.py | 16 +- .../skills/commands/__init__.py | 5 +- .../fastapi_startkit/skills/commands/list.py | 73 ----- .../skills/commands/skills.py | 80 +++++ .../fastapi_startkit/skills/commands/sync.py | 91 ------ .../src/fastapi_startkit/skills/parser.py | 74 +++++ .../src/fastapi_startkit/skills/provider.py | 44 +-- .../src/fastapi_startkit/skills/registry.py | 242 ++++++++------- .../fastapi_startkit/skills/rules/__init__.py | 8 - .../skills/rules/adapters/__init__.py | 6 - .../skills/rules/adapters/claude.py | 90 ------ .../skills/rules/adapters/gemini.py | 74 ----- .../skills/rules/commands/__init__.py | 6 - .../skills/rules/commands/list.py | 57 ---- .../skills/rules/commands/sync.py | 81 ----- .../fastapi_startkit/skills/rules/registry.py | 164 ---------- .../.ai/fastapi-startkit/database/SKILL.md | 107 +++++++ .../{fastapi-startkit => fastapi}/SKILL.md | 4 +- .../tests/skills/test_adapters.py | 45 ++- .../tests/skills/test_commands.py | 286 +++++++----------- fastapi_startkit/tests/skills/test_parser.py | 86 ++++++ .../tests/skills/test_registry.py | 169 ++++++----- 25 files changed, 793 insertions(+), 1105 deletions(-) delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/commands/list.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/commands/skills.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/parser.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/__init__.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/commands/__init__.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/database/SKILL.md rename fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/{fastapi-startkit => fastapi}/SKILL.md (96%) create mode 100644 fastapi_startkit/tests/skills/test_parser.py diff --git a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py index 4e307a7d..d498c5e5 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py @@ -1,24 +1,7 @@ -"""fastapi_startkit.skills — AI skill & rules registry and adapters. - -Skills and their nested rules both live under: - .ai/fastapi-startkit// - SKILL.md - rules/ - .md - -Run ``artisan skills:sync`` to deploy skills, ``artisan rules:sync`` for rules. -""" - -from .registry import Skill, SkillRegistry, SKILLS_BASE_PATH, _parse_frontmatter -from .provider import SkillsServiceProvider -from .rules import Rule, RulesRegistry +from .registry import SkillRegistry +from .provider import AISkillProvider __all__ = [ - "Skill", "SkillRegistry", - "SkillsServiceProvider", - "SKILLS_BASE_PATH", - "Rule", - "RulesRegistry", - "_parse_frontmatter", + "AISkillProvider", ] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py index d48f006b..9bf92e79 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from pathlib import Path -from typing import Sequence +from typing import Callable, Sequence from fastapi_startkit.skills.registry import Skill @@ -34,11 +34,19 @@ def __init__(self, base_path: Path | str | None = None) -> None: # ------------------------------------------------------------------ @abstractmethod - def render(self, skills: Sequence[Skill]) -> list[str]: + 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 ``skills:sync`` command). + (suitable for printing in the ``ai:skills`` command). """ @abstractmethod diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py index 5420c62a..6c1ebe6f 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Sequence +from typing import Callable, Sequence from fastapi_startkit.skills.registry import Skill from .base import BaseAdapter @@ -13,8 +13,10 @@ 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. Writes are - idempotent — the file is only (over)written when its content would change. + 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" @@ -23,16 +25,38 @@ class ClaudeAdapter(BaseAdapter): # Public API # ------------------------------------------------------------------ - def render(self, skills: Sequence[Skill]) -> list[str]: + 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) - written = self._write_idempotent(dest, content) - verb = "Synced" if written else "Unchanged" - messages.append(f"[claude] {verb} .claude/skills/{skill.name}/SKILL.md") + + 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] = [] @@ -65,18 +89,3 @@ def _build_content(skill: Skill) -> str: lines.append(skill.body) lines.append("") return "\n".join(lines) - - @staticmethod - def _write_idempotent(path: Path, content: str) -> bool: - """Write *content* to *path* only if it differs. - - Returns *True* when the file was (re)written, *False* when unchanged. - """ - if path.exists(): - existing = path.read_text(encoding="utf-8") - if existing == content: - return False - - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - return True diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py index ddc1f6de..da844aeb 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py @@ -9,7 +9,7 @@ from __future__ import annotations from pathlib import Path -from typing import Sequence +from typing import Callable, Sequence from fastapi_startkit.skills.registry import Skill from .base import BaseAdapter @@ -32,12 +32,20 @@ class GeminiAdapter(BaseAdapter): # Public API # ------------------------------------------------------------------ - def render(self, skills: Sequence[Skill]) -> list[str]: + 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 = "Synced" if changed else "Unchanged" - return [f"[gemini] {verb} GEMINI.md ({len(skills)} skill(s))"] + 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. diff --git a/fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py index 45c16137..13b7e335 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py @@ -1,6 +1,5 @@ """Cleo commands for the skills module.""" -from .sync import SkillsSyncCommand -from .list import SkillsListCommand +from .skills import SkillsCommand -__all__ = ["SkillsSyncCommand", "SkillsListCommand"] +__all__ = ["SkillsCommand"] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py deleted file mode 100644 index 9eb9509b..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py +++ /dev/null @@ -1,73 +0,0 @@ -"""skills:list — list skills available from registered providers.""" - -from __future__ import annotations - -from pathlib import Path - -from fastapi_startkit.console import Command - - -class SkillsListCommand(Command): - """List all skills declared by the registered providers. - - For each skill the command shows: - - * provider key - * skill name - * description - * sync status for Claude Code and Gemini CLI - - Example usage:: - - artisan skills:list - """ - - name = "skills:list" - description = "List skills declared by registered providers and their sync status." - - def handle(self) -> int: - from fastapi_startkit.skills.registry import SkillRegistry - - registry: SkillRegistry = self.container.make("skills.registry") - skills = registry.discover() - - if not skills: - self.line("No skills found in any registered provider.") - return 0 - - base_path: Path = self.container.base_path - - self.line("") - self.line(f" Found {len(skills)} skill(s):") - self.line("") - - header = f" {'PROVIDER':<20} {'NAME':<25} {'CLAUDE':<10} {'GEMINI':<10} DESCRIPTION" - self.line(header) - self.line(" " + "-" * (len(header) - 2)) - - for skill in skills: - claude_status = self._claude_status(skill.name, base_path) - gemini_status = self._gemini_status(base_path) - - desc = skill.description[:50] + "…" if len(skill.description) > 50 else skill.description - self.line(f" {skill.provider_key:<20} {skill.name:<25} {claude_status:<10} {gemini_status:<10} {desc}") - - self.line("") - return 0 - - # ------------------------------------------------------------------ - # Sync-status helpers - # ------------------------------------------------------------------ - - @staticmethod - def _claude_status(skill_name: str, base_path: Path) -> str: - skill_file = base_path / ".claude" / "skills" / skill_name / "SKILL.md" - return "synced" if skill_file.exists() else "pending" - - @staticmethod - def _gemini_status(base_path: Path) -> str: - gemini_md = base_path / "GEMINI.md" - if not gemini_md.exists(): - return "pending" - content = gemini_md.read_text(encoding="utf-8") - return "synced" if "" in content else "pending" diff --git a/fastapi_startkit/src/fastapi_startkit/skills/commands/skills.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/skills.py new file mode 100644 index 00000000..66d4d210 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/commands/skills.py @@ -0,0 +1,80 @@ +"""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/commands/sync.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py deleted file mode 100644 index dcbc99c3..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py +++ /dev/null @@ -1,91 +0,0 @@ -"""skills:sync — sync provider skills to one or more agent targets.""" - -from __future__ import annotations - -from cleo.helpers import option - -from fastapi_startkit.console import Command - - -class SkillsSyncCommand(Command): - """Sync provider skills into Claude Code / Gemini CLI skill files. - - Example usage:: - - artisan skills:sync - artisan skills:sync --target=claude - artisan skills:sync --target=gemini --prune - """ - - name = "skills:sync" - description = "Sync provider-declared skills to AI agent skill files." - - options = [ - option( - "target", - "t", - flag=False, - default="all", - description="Target adapter: claude | gemini | all (default: all)", - ), - option( - "prune", - None, - flag=True, - description="Remove skill files that are no longer declared by any provider.", - ), - ] - - def handle(self) -> int: - from fastapi_startkit.skills.registry import SkillRegistry - - registry: SkillRegistry = self.container.make("skills.registry") - skills = registry.discover() - - target = (self.option("target") or "all").lower() - do_prune = bool(self.option("prune")) - base_path = self.container.base_path - - adapters = self._resolve_adapters(target, base_path) - - if not adapters: - self.line(f"Unknown target '{target}'. Use: claude, gemini, all.") - return 1 - - if not skills: - self.line( - "No skills found. Publish stubs first: artisan provider:publish --provider=skills" - ) - return 0 - - self.line(f"Found {len(skills)} skill(s). Syncing to: {target}…") - self.line("") - - for adapter in adapters: - messages = adapter.render(skills) - for msg in messages: - self.line(f" {msg}") - - if do_prune: - prune_messages = adapter.prune(skills) - for msg in prune_messages: - self.line(f" {msg}") - - self.line("") - self.line("Done.") - return 0 - - @staticmethod - def _resolve_adapters(target: str, base_path) -> list: - from fastapi_startkit.skills.adapters import ClaudeAdapter, GeminiAdapter - - all_adapters = { - "claude": ClaudeAdapter, - "gemini": GeminiAdapter, - } - - if target == "all": - return [cls(base_path) for cls in all_adapters.values()] - - cls = all_adapters.get(target) - return [cls(base_path)] if cls else [] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/parser.py b/fastapi_startkit/src/fastapi_startkit/skills/parser.py new file mode 100644 index 00000000..e65280e8 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/parser.py @@ -0,0 +1,74 @@ +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 index 09dba3d2..ab65b604 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/provider.py @@ -1,51 +1,17 @@ -"""SkillsServiceProvider — registers skills and rules into the application.""" - from __future__ import annotations -from pathlib import Path - from fastapi_startkit.providers import Provider -_STUBS_DIR = Path(__file__).parent / "stubs" - - -class SkillsServiceProvider(Provider): - """Service provider that bootstraps the skills and rules modules. - Publish starter files with:: - - artisan provider:publish --provider=skills - - This copies: - - .ai/fastapi-startkit/fastapi-startkit/SKILL.md - """ - - provider_key = "skills" +class AISkillProvider(Provider): + provider_key = "ai_skill" def register(self) -> None: from fastapi_startkit.skills.registry import SkillRegistry - from fastapi_startkit.skills.rules.registry import RulesRegistry - self.app.bind("skills.registry", SkillRegistry(self.app)) - self.app.bind("rules.registry", RulesRegistry(self.app)) + self.app.bind(f"{self.provider_key}.skills.registry", SkillRegistry(self.app)) def boot(self) -> None: - from fastapi_startkit.skills.commands import SkillsSyncCommand, SkillsListCommand - from fastapi_startkit.skills.rules.commands import RulesSyncCommand, RulesListCommand - - self.commands( - [ - SkillsSyncCommand, - SkillsListCommand, - RulesSyncCommand, - RulesListCommand, - ] - ) + from fastapi_startkit.skills.commands import SkillsCommand - _skill_stub = _STUBS_DIR / ".ai" / "fastapi-startkit" / "fastapi-startkit" - self.publishes( - { - str(_skill_stub / "SKILL.md"): ".ai/fastapi-startkit/fastapi-startkit/SKILL.md", - }, - tag="skills", - ) + self.commands([SkillsCommand]) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/registry.py index e37d1bf0..6d60c5a1 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/registry.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/registry.py @@ -1,126 +1,158 @@ -"""SkillRegistry — discovers canonical skills from ``.ai/fastapi-startkit/``. +"""SkillRegistry — maps providers to their skill files and publishes them. -Each skill lives in a named subdirectory (or a ``SKILL.md`` directly under the -base) and carries a YAML front-matter block:: +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/``. - .ai/fastapi-startkit/ - fastapi-startkit/ - SKILL.md <- YAML frontmatter (name, description) + markdown body - rules/ - http-client.md - -Running ``artisan skills:sync`` deploys skills to every configured AI agent target. +* ``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 dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING +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 -#: Base directory for framework skills (relative to project root). -SKILLS_BASE_PATH = Path(".ai") / "fastapi-startkit" - - -@dataclass -class Skill: - """Canonical skill metadata parsed from a SKILL.md file.""" - - 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) - - -def _parse_frontmatter(text: str) -> tuple[dict, str]: - """Parse YAML front-matter. 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 +__all__ = ["Skill", "SkillRegistry", "STUBS_BASE_PATH"] - 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 +#: 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: - """Loads Skill objects from .ai/fastapi-startkit/*/SKILL.md.""" + skills = { + "fastapi": [ + ".ai/fastapi-startkit/fastapi/SKILL.md", + ], + "database": [ + ".ai/fastapi-startkit/database/SKILL.md", + ], + } def __init__(self, app: "Application") -> None: - self._app = app - self._skills: list[Skill] | None = None + self.application = app + self.parser = SkillParser() @property - def skills_base_path(self) -> Path: - return Path(self._app.base_path) / SKILLS_BASE_PATH + def base_path(self) -> Path: + return Path(self.application.base_path) - def discover(self) -> list[Skill]: - if self._skills is not None: - return self._skills - - base = self.skills_base_path - if not base.is_dir(): - self._skills = [] - return self._skills - - self._skills = [] - for skill_md in sorted(base.rglob("SKILL.md")): - skill = self._load(skill_md) - if skill is not None: - self._skills.append(skill) - - return self._skills - - def get(self, name: str) -> "Skill | None": - return next((s for s in self.discover() if s.name == name), None) - - def reset(self) -> None: - self._skills = None - - @staticmethod - def _load(skill_md: Path) -> "Skill | None": - try: - text = skill_md.read_text(encoding="utf-8") - except OSError: - return None - - meta, body = _parse_frontmatter(text) - name = (meta.get("name") or skill_md.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=skill_md, - body=body.strip(), - metadata=extra, + @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/rules/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py deleted file mode 100644 index ec91e0b8..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Rules sub-system — per-topic rule files nested inside each skill directory. - -Layout: .ai/fastapi-startkit/skill//rules/.md -""" - -from .registry import Rule, RulesRegistry, SKILLS_BASE_PATH - -__all__ = ["Rule", "RulesRegistry", "SKILLS_BASE_PATH"] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/__init__.py deleted file mode 100644 index ac5b7229..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Adapters for deploying rules to AI agent targets.""" - -from .claude import ClaudeRulesAdapter -from .gemini import GeminiRulesAdapter - -__all__ = ["ClaudeRulesAdapter", "GeminiRulesAdapter"] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py deleted file mode 100644 index a5abc57d..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py +++ /dev/null @@ -1,90 +0,0 @@ -"""ClaudeRulesAdapter — deploys rules to ``.claude/rules//.md``. - -Claude Code reads rules **only** from ``.claude/rules/``. Rules written -anywhere else (e.g. ``.claude/skills//rules/``) are silently ignored. - -Output layout:: - - .claude/rules/ - fastapi-best-practices/ - http-client.md - validation.md - orm-best-practices/ - queries.md - -``prune()`` scans ``.claude/rules/`` independently of the skills adapter — -it removes individual rule files (and empty skill sub-dirs) that are no -longer present in the registry. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Sequence - -from fastapi_startkit.skills.adapters.base import BaseAdapter -from fastapi_startkit.skills.rules.registry import Rule - - -class ClaudeRulesAdapter(BaseAdapter): - """Writes each rule to ``.claude/rules//.md``. - - Writes are idempotent — the file is only (re)written when content changes. - ``prune()`` scans ``.claude/rules/`` and removes stale rule files and - empty skill sub-directories. It operates independently of - ``ClaudeAdapter.prune()``. - """ - - name = "claude-rules" - - def render(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] - messages: list[str] = [] - for rule in rules: - dest = self._rule_path(rule.skill_name, rule.name) - written = self._write_idempotent(dest, rule.body) - verb = "Synced" if written else "Unchanged" - messages.append(f"[claude] {verb} .claude/rules/{rule.skill_name}/{rule.name}.md") - return messages - - def prune(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] - """Remove stale rule files from ``.claude/rules/``. - - Iterates every ``/.md`` under ``.claude/rules/`` and - removes any file whose ``(skill_name, rule_name)`` pair is absent from - *rules*. Empty skill sub-directories are removed afterwards. - """ - messages: list[str] = [] - live = {(r.skill_name, r.name) for r in rules} - - rules_root = self.base_path / ".claude" / "rules" - if not rules_root.is_dir(): - return messages - - for skill_dir in sorted(rules_root.iterdir()): - if not skill_dir.is_dir(): - continue - for rule_file in sorted(skill_dir.glob("*.md")): - key = (skill_dir.name, rule_file.stem) - if key not in live: - rule_file.unlink() - messages.append(f"[claude] Pruned .claude/rules/{skill_dir.name}/{rule_file.name}") - # Remove empty skill sub-directory - if skill_dir.is_dir() and not any(skill_dir.iterdir()): - skill_dir.rmdir() - - return messages - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - def _rule_path(self, skill_name: str, rule_name: str) -> Path: - return self.base_path / ".claude" / "rules" / skill_name / f"{rule_name}.md" - - @staticmethod - def _write_idempotent(path: Path, content: str) -> bool: - if path.exists() and path.read_text(encoding="utf-8") == content: - return False - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - return True diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py deleted file mode 100644 index 943c4a47..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py +++ /dev/null @@ -1,74 +0,0 @@ -"""GeminiRulesAdapter — splices rules into ``GEMINI.md``, grouped by skill.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Sequence - -from fastapi_startkit.skills.adapters.base import BaseAdapter -from fastapi_startkit.skills.rules.registry import Rule - -_MARKER_START = "" -_MARKER_END = "" - - -class GeminiRulesAdapter(BaseAdapter): - """Writes rules into the ```` block of ``GEMINI.md``. - - Rules are grouped under their parent skill as a second-level heading. - Content outside the markers is never modified. - """ - - name = "gemini-rules" - - def render(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] - gemini_md = self.base_path / "GEMINI.md" - section = self._build_section(rules) - changed = self._update_file(gemini_md, section) - verb = "Synced" if changed else "Unchanged" - return [f"[gemini] {verb} GEMINI.md rules section ({len(rules)} rule(s))"] - - def prune(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] - """Re-render with the current (shorter) rule list — equivalent to pruning.""" - return self.render(rules) - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - @staticmethod - def _build_section(rules: Sequence[Rule]) -> str: - # Group rules by skill_name preserving order - by_skill: dict[str, list[Rule]] = {} - for rule in rules: - by_skill.setdefault(rule.skill_name, []).append(rule) - - parts = [_MARKER_START] - for skill_name, skill_rules in by_skill.items(): - parts.append(f"\n### {skill_name}\n") - for rule in skill_rules: - parts.append(f"\n#### {rule.name}\n") - if rule.description: - parts.append(f"{rule.description}\n") - if rule.body: - parts.append(f"\n{rule.body}\n") - parts.append(_MARKER_END) - return "\n".join(parts) - - def _update_file(self, path: Path, section: str) -> bool: - original = path.read_text(encoding="utf-8") if path.exists() else "" - 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: - start = original.find(_MARKER_START) - end = original.find(_MARKER_END) - if start != -1 and end != -1 and end > start: - return original[:start] + section + original[end + len(_MARKER_END) :] - 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/rules/commands/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/__init__.py deleted file mode 100644 index e6e763c5..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Cleo commands for the rules sub-system.""" - -from .sync import RulesSyncCommand -from .list import RulesListCommand - -__all__ = ["RulesSyncCommand", "RulesListCommand"] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py deleted file mode 100644 index ed834141..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py +++ /dev/null @@ -1,57 +0,0 @@ -"""rules:list — list all rules nested inside skill directories.""" - -from __future__ import annotations - -from pathlib import Path - -from fastapi_startkit.console import Command - - -class RulesListCommand(Command): - """List all rule files and their deployment status. - - Example usage:: - - artisan rules:list - """ - - name = "rules:list" - description = "List skill-nested rules and their AI agent sync status." - - def handle(self) -> int: - from fastapi_startkit.skills.rules.registry import RulesRegistry - - registry: RulesRegistry = self.container.make("rules.registry") - rules = registry.discover() - - if not rules: - self.line( - "No rules found. Publish stubs first: artisan provider:publish --provider=skills" - ) - return 0 - - base_path: Path = self.container.base_path - - self.line("") - self.line(f" Found {len(rules)} rule(s):") - self.line("") - - header = f" {'SKILL':<28} {'RULE':<25} {'CLAUDE':<10} {'GEMINI':<10}" - self.line(header) - self.line(" " + "-" * (len(header) - 2)) - - for rule in rules: - claude_dest = base_path / ".claude" / "rules" / rule.skill_name / f"{rule.name}.md" - claude_status = "synced" if claude_dest.exists() else "pending" - gemini_status = self._gemini_status(base_path) - self.line(f" {rule.skill_name:<28} {rule.name:<25} {claude_status:<10} {gemini_status:<10}") - - self.line("") - return 0 - - @staticmethod - def _gemini_status(base_path: Path) -> str: - gemini_md = base_path / "GEMINI.md" - if not gemini_md.exists(): - return "pending" - return "synced" if "" in gemini_md.read_text(encoding="utf-8") else "pending" diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py deleted file mode 100644 index 2d3edc03..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py +++ /dev/null @@ -1,81 +0,0 @@ -"""rules:sync — sync per-topic rule files to AI agent targets.""" - -from __future__ import annotations - -from cleo.helpers import option - -from fastapi_startkit.console import Command - - -class RulesSyncCommand(Command): - """Deploy ``rules/*.md`` files to AI agent rule directories. - - Example usage:: - - artisan rules:sync - artisan rules:sync --target=claude - artisan rules:sync --target=gemini --prune - """ - - name = "rules:sync" - description = "Sync rules/*.md files to AI agent rule directories." - - options = [ - option( - "target", - "t", - flag=False, - default="all", - description="Target adapter: claude | gemini | all (default: all)", - ), - option( - "prune", - None, - flag=True, - description="Remove rule files that no longer exist in rules/.", - ), - ] - - def handle(self) -> int: - from fastapi_startkit.skills.rules.registry import RulesRegistry - - registry: RulesRegistry = self.container.make("rules.registry") - rules = registry.discover() - - target = (self.option("target") or "all").lower() - do_prune = bool(self.option("prune")) - base_path = self.container.base_path - - adapters = self._resolve_adapters(target, base_path) - - if not adapters: - self.line(f"Unknown target '{target}'. Use: claude, gemini, all.") - return 1 - - if not rules: - self.line("No rules found in rules/. Create rules/*.md files first.") - return 0 - - self.line(f"Found {len(rules)} rule(s). Syncing to: {target}…") - self.line("") - - for adapter in adapters: - for msg in adapter.render(rules): - self.line(f" {msg}") - if do_prune: - for msg in adapter.prune(rules): - self.line(f" {msg}") - - self.line("") - self.line("Done.") - return 0 - - @staticmethod - def _resolve_adapters(target: str, base_path) -> list: - from fastapi_startkit.skills.rules.adapters import ClaudeRulesAdapter, GeminiRulesAdapter - - all_adapters = {"claude": ClaudeRulesAdapter, "gemini": GeminiRulesAdapter} - if target == "all": - return [cls(base_path) for cls in all_adapters.values()] - cls = all_adapters.get(target) - return [cls(base_path)] if cls else [] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py deleted file mode 100644 index 21818ee2..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py +++ /dev/null @@ -1,164 +0,0 @@ -"""RulesRegistry — discovers per-skill rule files nested inside each skill directory. - -Rules live **inside** their parent skill, mirroring the Laravel Boost convention:: - - .ai/fastapi-startkit/ - fastapi-startkit/ - SKILL.md - rules/ - http-client.md - validation.md - orm-best-practices/ - SKILL.md - rules/ - queries.md - -Each rule is a plain Markdown file. The rule **name** is the file stem -(``http-client.md`` → ``"http-client"``). The owning skill is determined -from the parent directory name two levels up (``fastapi-best-practices``). - -Optional YAML front-matter may supply an explicit ``name`` or ``description``. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from fastapi_startkit.application import Application - -#: Root under which all skill (and nested rule) directories live. -SKILLS_BASE_PATH = Path(".ai") / "fastapi-startkit" - - -@dataclass -class Rule: - """A per-topic coding rule nested inside a skill directory.""" - - name: str # file stem, e.g. "http-client" - skill_name: str # owning skill directory name, e.g. "fastapi-best-practices" - path: Path # absolute path to the .md source file - body: str = field(default="", repr=False) - description: str = "" - - -def _parse_frontmatter(text: str) -> tuple[dict, str]: - """Parse optional YAML front-matter; return ``(meta, body)``.""" - lines = text.splitlines(keepends=True) - if not lines or lines[0].strip() != "---": - return {}, text - - end_idx: int | None = 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 # type: ignore[import] - - 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 - - -class RulesRegistry: - """Loads :class:`Rule` objects from nested ``rules/`` dirs inside each skill. - - Scan path: ``{base_path}/.ai/fastapi-startkit/*/rules/*.md`` - - Usage:: - - from fastapi_startkit.application import app - registry = app().make("rules.registry") - rules = registry.discover() - rules_for_skill = registry.for_skill("fastapi-best-practices") - """ - - def __init__(self, app: "Application") -> None: - self._app = app - self._rules: list[Rule] | None = None - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - @property - def skills_base_path(self) -> Path: - """Absolute path to the skill root (parent of all skill dirs).""" - return Path(self._app.base_path) / SKILLS_BASE_PATH - - def discover(self) -> list[Rule]: - """Return (and cache) all rules found across all skill directories.""" - if self._rules is not None: - return self._rules - - base = self.skills_base_path - if not base.is_dir(): - self._rules = [] - return self._rules - - self._rules = [] - for skill_dir in sorted(base.iterdir()): - if not skill_dir.is_dir(): - continue - rules_dir = skill_dir / "rules" - if not rules_dir.is_dir(): - continue - for md_file in sorted(rules_dir.glob("*.md")): - rule = self._load(md_file, skill_name=skill_dir.name) - if rule is not None: - self._rules.append(rule) - - return self._rules - - def for_skill(self, skill_name: str) -> list[Rule]: - """Return all rules belonging to *skill_name*.""" - return [r for r in self.discover() if r.skill_name == skill_name] - - def get(self, name: str, skill_name: str | None = None) -> Rule | None: - """Return the first :class:`Rule` matching *name*, optionally scoped to *skill_name*.""" - candidates = self.for_skill(skill_name) if skill_name else self.discover() - return next((r for r in candidates if r.name == name), None) - - def reset(self) -> None: - """Invalidate the discovery cache.""" - self._rules = None - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - @staticmethod - def _load(md_file: Path, skill_name: str) -> Rule | None: - try: - text = md_file.read_text(encoding="utf-8") - except OSError: - return None - - meta, body = _parse_frontmatter(text) - name = (meta.get("name") or md_file.stem or "").strip() - if not name: - return None - - return Rule( - name=name, - skill_name=skill_name, - path=md_file, - body=body.strip() if body.strip() else text.strip(), - description=(meta.get("description") or "").strip(), - ) 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 new file mode 100644 index 00000000..da7503f5 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/database/SKILL.md @@ -0,0 +1,107 @@ +--- +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-startkit/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md similarity index 96% rename from fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi-startkit/SKILL.md rename to fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md index dfa2b418..ace0a63b 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi-startkit/SKILL.md +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md @@ -78,14 +78,14 @@ class UserStoreRequest(BaseModel): and use JsonApiResource to return JSON response from the controller: ```python -from fastapi_startkit.resources import JsonApiResource +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 JsonApiResource(user) + return JsonResource(user) ``` ## Architecture diff --git a/fastapi_startkit/tests/skills/test_adapters.py b/fastapi_startkit/tests/skills/test_adapters.py index a43ce779..20698c2d 100644 --- a/fastapi_startkit/tests/skills/test_adapters.py +++ b/fastapi_startkit/tests/skills/test_adapters.py @@ -35,25 +35,52 @@ def test_render_creates_skill_file(self, tmp_path): content = skill_file.read_text() assert "name: my-skill" in content assert "description: Does stuff" in content - assert any("Synced" in m for m in messages) + assert any("Published" in m for m in messages) - def test_render_idempotent(self, tmp_path): + def test_render_skips_existing_without_force_or_confirm(self, tmp_path): adapter = ClaudeAdapter(base_path=tmp_path) - skills = [make_skill("my-skill")] + skills = [make_skill("my-skill", "Old description")] adapter.render(skills) - messages = adapter.render(skills) + # Second render: file exists, no force and no confirm callback → skip. + messages = adapter.render([make_skill("my-skill", "New description")]) - assert any("Unchanged" in m for m in messages) + 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_updates_changed_content(self, tmp_path): + 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")]) + 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("Synced" in m for m in messages) + 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) @@ -110,7 +137,7 @@ def test_render_creates_gemini_md(self, tmp_path): assert _MARKER_END in content assert "my-skill" in content assert "Does stuff" in content - assert any("Synced" in m for m in messages) + assert any("Updated" in m for m in messages) def test_render_idempotent(self, tmp_path): adapter = GeminiAdapter(base_path=tmp_path) diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py index f152c237..36e4a1a7 100644 --- a/fastapi_startkit/tests/skills/test_commands.py +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -1,4 +1,11 @@ -"""Tests for skills:sync, skills:list, rules:sync, rules:list commands.""" +"""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 framework's +real ``FastAPIProvider`` (``fastapi``) and ``DatabaseProvider`` (``database``) +declare the bundled skills, so the tests register the actual providers rather +than stubs. ``AISkillProvider`` binds the registry the command resolves. +""" from __future__ import annotations @@ -6,12 +13,10 @@ from fastapi_startkit.application import Application from fastapi_startkit.container.container import Container -from fastapi_startkit.skills.registry import SkillRegistry, SKILLS_BASE_PATH -from fastapi_startkit.skills.rules.registry import RulesRegistry -from fastapi_startkit.skills.commands.sync import SkillsSyncCommand -from fastapi_startkit.skills.commands.list import SkillsListCommand -from fastapi_startkit.skills.rules.commands.sync import RulesSyncCommand -from fastapi_startkit.skills.rules.commands.list import RulesListCommand +from fastapi_startkit.masoniteorm import DatabaseProvider, Migrator, Model +from fastapi_startkit.fastapi.providers.fastapi_provider import FastAPIProvider +from fastapi_startkit.skills import AISkillProvider +from fastapi_startkit.skills.commands import SkillsCommand @pytest.fixture(autouse=True) @@ -21,224 +26,165 @@ def restore_container(): Container._instance = original -@pytest.fixture -def app(tmp_path): - return Application(base_path=tmp_path, env="testing") +@pytest.fixture(autouse=True) +def restore_db_manager(): + """DatabaseProvider.register() sets these class globals (absent by default). + Snapshot and restore so registering it here can't leak into other tests.""" + _MISSING = object() + saved = {cls: getattr(cls, "db_manager", _MISSING) for cls in (Model, Migrator)} + yield + for cls, value in saved.items(): + if value is _MISSING: + if hasattr(cls, "db_manager"): + delattr(cls, "db_manager") + else: + cls.db_manager = value -def _write_skill_md(tmp_path, name, description): - skill_dir = tmp_path / SKILLS_BASE_PATH / name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text( - f"---\nname: {name}\ndescription: {description}\n---\nBody.\n", - encoding="utf-8", +@pytest.fixture +def app(tmp_path): + """App with the real skill-declaring providers and a bound registry.""" + return Application( + base_path=tmp_path, + env="testing", + providers=[AISkillProvider, FastAPIProvider, DatabaseProvider], ) -def _write_rule_md(tmp_path, skill_name, rule_name, body="Rule body."): - """Write a rule nested inside a skill directory.""" - rules_dir = tmp_path / SKILLS_BASE_PATH / skill_name / "rules" - rules_dir.mkdir(parents=True, exist_ok=True) - (rules_dir / f"{rule_name}.md").write_text(body, encoding="utf-8") +@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(cmd_class, container, args=None, choice="all"): + """Instantiate a command, stub its Cleo I/O, and run handle(). -def _run(cmd_class, container, args=None): + Returns ``(exit_code, lines)`` where ``lines`` is everything written via + ``line``/``info``. + """ cmd = cmd_class() cmd.set_container(container) + opts = {} for arg in args or []: if arg.startswith("--"): k = arg.lstrip("-").split("=")[0] v = arg.split("=")[1] if "=" in arg else True opts[k] = v + lines = [] cmd.option = lambda k, default=None: opts.get(k, default) cmd.line = lambda msg, *a, **kw: lines.append(msg) cmd.info = lambda msg, *a, **kw: lines.append(msg) + cmd.choice = lambda *a, **kw: choice + cmd.confirm = lambda *a, **kw: False return cmd.handle(), lines # =========================================================================== -# skills:sync +# ai:skills --list # =========================================================================== -class TestSkillsSyncCommand: - def test_sync_all_writes_claude_and_gemini(self, tmp_path, app): - _write_skill_md(tmp_path, "orm-routing", "ORM routing") - app.bind("skills.registry", SkillRegistry(app)) - code, _ = _run(SkillsSyncCommand, app, ["--target=all"]) - assert code == 0 - assert (tmp_path / ".claude" / "skills" / "orm-routing" / "SKILL.md").exists() - assert (tmp_path / "GEMINI.md").exists() - - def test_sync_claude_only(self, tmp_path, app): - _write_skill_md(tmp_path, "console-commands", "Commands") - app.bind("skills.registry", SkillRegistry(app)) - _run(SkillsSyncCommand, app, ["--target=claude"]) - assert (tmp_path / ".claude" / "skills" / "console-commands" / "SKILL.md").exists() - assert not (tmp_path / "GEMINI.md").exists() - - def test_sync_gemini_only(self, tmp_path, app): - _write_skill_md(tmp_path, "fastapi-routing", "Routing") - app.bind("skills.registry", SkillRegistry(app)) - _run(SkillsSyncCommand, app, ["--target=gemini"]) - assert not (tmp_path / ".claude").exists() - assert (tmp_path / "GEMINI.md").exists() - - def test_sync_unknown_target_returns_error(self, tmp_path, app): - app.bind("skills.registry", SkillRegistry(app)) - code, lines = _run(SkillsSyncCommand, app, ["--target=codex"]) - assert code == 1 - - def test_sync_no_skills_exits_gracefully(self, tmp_path, app): - app.bind("skills.registry", SkillRegistry(app)) - code, lines = _run(SkillsSyncCommand, app) +class TestSkillsListCommand: + def test_list_shows_declaring_providers(self, app): + code, lines = _run(SkillsCommand, app, ["--list"]) assert code == 0 - assert any("No skills" in l for l in lines) - - def test_sync_prune_removes_old_skills(self, tmp_path, app): - old_dir = tmp_path / ".claude" / "skills" / "old-skill" - old_dir.mkdir(parents=True) - (old_dir / "SKILL.md").write_text("old") - _write_skill_md(tmp_path, "new-skill", "New") - app.bind("skills.registry", SkillRegistry(app)) - _run(SkillsSyncCommand, app, ["--target=claude", "--prune"]) - assert not old_dir.exists() - - def test_command_name(self): - assert SkillsSyncCommand().name == "skills:sync" - - -# =========================================================================== -# skills:list -# =========================================================================== - + out = "\n".join(lines) + assert "fastapi" in out + assert "database" in out -class TestSkillsListCommand: - def test_list_shows_skills(self, tmp_path, app): - _write_skill_md(tmp_path, "fastapi-routing", "FastAPI routing") - _write_skill_md(tmp_path, "orm-queries", "ORM queries") - app.bind("skills.registry", SkillRegistry(app)) - code, lines = _run(SkillsListCommand, app) + def test_list_is_default_when_no_flags(self, app): + code, lines = _run(SkillsCommand, app) assert code == 0 out = "\n".join(lines) - assert "fastapi-routing" in out - assert "orm-queries" in out + assert "fastapi" in out + assert "database" in out - def test_list_no_skills_shows_message(self, tmp_path, app): - app.bind("skills.registry", SkillRegistry(app)) - code, lines = _run(SkillsListCommand, app) + def test_list_no_skills_shows_message(self, empty_app): + code, lines = _run(SkillsCommand, empty_app, ["--list"]) assert code == 0 assert any("No skills" in l for l in lines) def test_command_name(self): - assert SkillsListCommand().name == "skills:list" + assert SkillsCommand().name == "ai:skills" # =========================================================================== -# rules:sync (rules nested inside skills) +# ai:skills --sync # =========================================================================== -class TestRulesSyncCommand: - def test_sync_claude_creates_nested_rule_file(self, tmp_path, app): - _write_rule_md(tmp_path, "fastapi-best-practices", "http-client", "Always set timeout.") - app.bind("rules.registry", RulesRegistry(app)) - code, _ = _run(RulesSyncCommand, app, ["--target=claude"]) - assert code == 0 - dest = tmp_path / ".claude" / "rules" / "fastapi-best-practices" / "http-client.md" - assert dest.exists() - assert "Always set timeout." in dest.read_text() - - def test_sync_gemini_updates_gemini_md(self, tmp_path, app): - _write_rule_md(tmp_path, "fastapi-best-practices", "http-client", "Always set timeout.") - app.bind("rules.registry", RulesRegistry(app)) - code, _ = _run(RulesSyncCommand, app, ["--target=gemini"]) +class TestSkillsSyncCommand: + def test_sync_claude_publishes_stubs_and_skill_files(self, app, tmp_path): + code, _ = _run(SkillsCommand, app, ["--sync", "--target=claude"]) assert code == 0 - content = (tmp_path / "GEMINI.md").read_text() - assert "" in content - assert "http-client" in content - assert "fastapi-best-practices" in content - - def test_sync_all_writes_both(self, tmp_path, app): - _write_rule_md(tmp_path, "orm-best-practices", "queries", "Use async ORM.") - app.bind("rules.registry", RulesRegistry(app)) - code, _ = _run(RulesSyncCommand, app, ["--target=all"]) + # Stubs are copied into the project's .ai/ tree. + assert (tmp_path / ".ai" / "fastapi-startkit" / "fastapi" / "SKILL.md").exists() + assert (tmp_path / ".ai" / "fastapi-startkit" / "database" / "SKILL.md").exists() + # ClaudeAdapter writes one dir per skill name (from stub front-matter). + assert (tmp_path / ".claude" / "skills" / "fastapi-startkit" / "SKILL.md").exists() + assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() + assert not (tmp_path / "GEMINI.md").exists() + + def test_sync_gemini_only(self, app, tmp_path): + code, _ = _run(SkillsCommand, app, ["--sync", "--target=gemini"]) assert code == 0 - assert (tmp_path / ".claude" / "rules" / "orm-best-practices" / "queries.md").exists() assert (tmp_path / "GEMINI.md").exists() + assert not (tmp_path / ".claude").exists() - def test_sync_prune_removes_stale_rule_within_skill(self, tmp_path, app): - # Stale rule in a skill that still has other rules - stale = tmp_path / ".claude" / "rules" / "fastapi-best-practices" / "old-rule.md" - stale.parent.mkdir(parents=True) - stale.write_text("stale") - # Only http-client is in the registry now - _write_rule_md(tmp_path, "fastapi-best-practices", "http-client", "body") - app.bind("rules.registry", RulesRegistry(app)) - _run(RulesSyncCommand, app, ["--target=claude", "--prune"]) - assert not stale.exists() - - def test_prune_removes_stale_rule_skill_subdir(self, tmp_path, app): - """ClaudeRulesAdapter.prune() scans .claude/rules/, not .claude/skills/.""" - from fastapi_startkit.skills.rules.adapters.claude import ClaudeRulesAdapter - - # Pre-populate .claude/rules/ with a stale skill subdir - stale_dir = tmp_path / ".claude" / "rules" / "dead-skill" - stale_dir.mkdir(parents=True) - (stale_dir / "old-rule.md").write_text("stale rule") + def test_sync_all_writes_both(self, app, tmp_path): + code, _ = _run(SkillsCommand, app, ["--sync", "--target=all"]) + assert code == 0 + assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() + assert (tmp_path / "GEMINI.md").exists() - # Prune with an empty rules list — stale dir should be removed - adapter = ClaudeRulesAdapter(base_path=tmp_path) - messages = adapter.prune([]) - assert not stale_dir.exists() - assert any("Pruned" in m for m in messages) + def test_prune_implies_sync(self, app, tmp_path): + # --prune alone (no --sync) still triggers a sync; target is prompted, + # and our stubbed choice returns "all". + code, _ = _run(SkillsCommand, app, ["--prune"]) + assert code == 0 + assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() - def test_no_rules_exits_gracefully(self, tmp_path, app): - app.bind("rules.registry", RulesRegistry(app)) - code, lines = _run(RulesSyncCommand, app) + def test_target_flag_implies_sync(self, app, tmp_path): + code, _ = _run(SkillsCommand, app, ["--target=claude"]) assert code == 0 - assert any("No rules" in l for l in lines) + assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() - def test_unknown_target_returns_error(self, tmp_path, app): - app.bind("rules.registry", RulesRegistry(app)) - code, _ = _run(RulesSyncCommand, app, ["--target=codex"]) - assert code == 1 + def test_sync_prompts_for_target_when_omitted(self, app, tmp_path): + code, _ = _run(SkillsCommand, app, ["--sync"], choice="claude") + assert code == 0 + assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() + assert not (tmp_path / "GEMINI.md").exists() - def test_command_name(self): - assert RulesSyncCommand().name == "rules:sync" + def test_sync_unknown_target_reports_error(self, app): + code, lines = _run(SkillsCommand, app, ["--sync", "--target=codex"]) + # Unknown target is reported as a message, not a non-zero exit. + assert code == 0 + assert any("Unknown target" in l for l in lines) + def test_sync_no_skills_exits_gracefully(self, empty_app): + code, lines = _run(SkillsCommand, empty_app, ["--sync", "--target=claude"]) + assert code == 0 + assert any("No skills" in l for l in lines) -# =========================================================================== -# rules:list -# =========================================================================== + 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(SkillsCommand, app, ["--sync", "--target=claude", "--prune"]) -class TestRulesListCommand: - def test_list_shows_rules(self, tmp_path, app): - _write_rule_md(tmp_path, "fastapi-best-practices", "http-client") - _write_rule_md(tmp_path, "fastapi-best-practices", "validation") - app.bind("rules.registry", RulesRegistry(app)) - code, lines = _run(RulesListCommand, app) - assert code == 0 - out = "\n".join(lines) - assert "http-client" in out - assert "validation" in out + assert not stale.exists() + assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() - def test_list_shows_synced_status(self, tmp_path, app): - _write_rule_md(tmp_path, "fastapi-best-practices", "http-client") - dest = tmp_path / ".claude" / "rules" / "fastapi-best-practices" / "http-client.md" + 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("synced content") - app.bind("rules.registry", RulesRegistry(app)) - _, lines = _run(RulesListCommand, app) - assert "synced" in "\n".join(lines) + dest.write_text("user edited stub") - def test_no_rules_message(self, tmp_path, app): - app.bind("rules.registry", RulesRegistry(app)) - _, lines = _run(RulesListCommand, app) - assert any("No rules" in l for l in lines) + _run(SkillsCommand, app, ["--sync", "--target=claude", "--force"]) - def test_command_name(self): - assert RulesListCommand().name == "rules:list" + 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 new file mode 100644 index 00000000..c62f5c4c --- /dev/null +++ b/fastapi_startkit/tests/skills/test_parser.py @@ -0,0 +1,86 @@ +"""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 index 96e8ac10..995efe2c 100644 --- a/fastapi_startkit/tests/skills/test_registry.py +++ b/fastapi_startkit/tests/skills/test_registry.py @@ -1,18 +1,20 @@ -"""Tests for SkillRegistry reading from .ai/fastapi-startkit/*/SKILL.md.""" +"""Tests for the provider-driven SkillRegistry. -from __future__ import annotations +A provider "declares" skills when its ``provider_key`` is a key in +:attr:`SkillRegistry.skills`. The framework ships a matching ``SKILL.md`` stub +for each declared destination; :meth:`SkillRegistry.discover` prefers a project +copy under ``.ai/`` and falls back to the bundled stub. +""" -from pathlib import Path +from __future__ import annotations import pytest from fastapi_startkit.application import Application from fastapi_startkit.container.container import Container -from fastapi_startkit.skills.registry import ( - SkillRegistry, - SKILLS_BASE_PATH, - _parse_frontmatter, -) +from fastapi_startkit.masoniteorm import DatabaseProvider, Migrator, Model +from fastapi_startkit.fastapi.providers.fastapi_provider import FastAPIProvider +from fastapi_startkit.skills.registry import SkillRegistry, STUBS_BASE_PATH @pytest.fixture(autouse=True) @@ -22,109 +24,120 @@ def restore_container(): Container._instance = original +@pytest.fixture(autouse=True) +def restore_db_manager(): + """DatabaseProvider.register() sets these class globals (absent by default). + Snapshot and restore so registering it here can't leak into other tests.""" + _MISSING = object() + saved = {cls: getattr(cls, "db_manager", _MISSING) for cls in (Model, Migrator)} + yield + for cls, value in saved.items(): + if value is _MISSING: + if hasattr(cls, "db_manager"): + delattr(cls, "db_manager") + else: + cls.db_manager = value + + @pytest.fixture def app(tmp_path): + """App with the real fastapi + database skill-declaring providers.""" + return Application( + base_path=tmp_path, + env="testing", + providers=[FastAPIProvider, DatabaseProvider], + ) + + +@pytest.fixture +def empty_app(tmp_path): return Application(base_path=tmp_path, env="testing") -def _write_skill(tmp_path: Path, name: str, description: str, body: str = "") -> Path: - skill_dir = tmp_path / SKILLS_BASE_PATH / name - skill_dir.mkdir(parents=True, exist_ok=True) - skill_md = skill_dir / "SKILL.md" - lines = ["---", f"name: {name}", f"description: {description}", "---"] - if body: - lines += ["", body] - skill_md.write_text("\n".join(lines), encoding="utf-8") - return skill_md +# --------------------------------------------------------------------------- +# get_providers +# --------------------------------------------------------------------------- -# -- _parse_frontmatter -- +def test_get_providers_returns_declaring_keys(app): + keys = set(SkillRegistry(app).get_providers()) + assert keys == {"fastapi", "database"} -def test_parse_frontmatter_basic(): - text = "---\nname: fastapi-best-practices\ndescription: Apply for FastAPI code.\n---\nBody content here.\n" - meta, body = _parse_frontmatter(text) - assert meta["name"] == "fastapi-best-practices" - assert meta["description"] == "Apply for FastAPI code." - assert "Body content here." in body +def test_get_providers_empty_when_none_declare(empty_app): + assert list(SkillRegistry(empty_app).get_providers()) == [] -def test_parse_frontmatter_missing_fence(): - text = "No front-matter here." - meta, body = _parse_frontmatter(text) - assert meta == {} - assert body == text +# --------------------------------------------------------------------------- +# discover +# --------------------------------------------------------------------------- -def test_parse_frontmatter_unclosed_fence(): - meta, body = _parse_frontmatter("---\nname: oops\n") - assert meta == {} +def test_discover_loads_skills_from_bundled_stubs(app): + skills = SkillRegistry(app).discover() + # Names come from each stub's front-matter. + assert {s.name for s in skills} == {"fastapi-startkit", "database"} + assert {s.provider_key for s in skills} == {"fastapi", "database"} -# -- SkillRegistry -- +def test_discover_empty_when_no_declaring_providers(empty_app): + assert SkillRegistry(empty_app).discover() == [] -def test_registry_discovers_skills_from_skill_dirs(tmp_path, app): - _write_skill(tmp_path, "fastapi-routing", "FastAPI routing helpers", "Use Router.") - _write_skill(tmp_path, "orm-queries", "ORM query helpers", "Use Model.where().") +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 len(skills) == 2 - assert {s.name for s in skills} == {"fastapi-routing", "orm-queries"} + names = {s.name for s in skills} + assert "my-fastapi" in names + assert "fastapi-startkit" not in names + edited = next(s for s in skills if s.name == "my-fastapi") + assert edited.path == dest -def test_registry_returns_empty_when_skills_dir_missing(tmp_path, app): - assert SkillRegistry(app).discover() == [] - -def test_registry_skill_path_points_to_skill_md(tmp_path, app): - path = _write_skill(tmp_path, "my-skill", "desc") - skills = SkillRegistry(app).discover() - assert skills[0].path == path +# --------------------------------------------------------------------------- +# publish +# --------------------------------------------------------------------------- -def test_registry_skill_body_captured(tmp_path, app): - _write_skill(tmp_path, "orm", "ORM.", body="Use `Model.where(...)`") - skill = SkillRegistry(app).get("orm") - assert skill is not None - assert "Model.where" in skill.body +def test_publish_writes_stubs_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-startkit" / "SKILL.md").exists() + assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() + assert any("Syncing" in m for m in messages) -def test_registry_uses_dir_name_fallback(tmp_path, app): - skill_dir = tmp_path / SKILLS_BASE_PATH / "fallback-skill" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text("No front-matter.", encoding="utf-8") - skills = SkillRegistry(app).discover() - assert skills[0].name == "fallback-skill" +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_registry_caches_results(tmp_path, app): - _write_skill(tmp_path, "s", "d") - r = SkillRegistry(app) - assert r.discover() is r.discover() +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_registry_reset_clears_cache(tmp_path, app): - _write_skill(tmp_path, "s", "d") - r = SkillRegistry(app) - r.discover() - r.reset() - assert r._skills is None +def test_publish_all_writes_both(app, tmp_path): + SkillRegistry(app).publish(target="all") + assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() + assert (tmp_path / "GEMINI.md").exists() -def test_registry_get_returns_none_for_unknown(tmp_path, app): - _write_skill(tmp_path, "s", "d") - assert SkillRegistry(app).get("does-not-exist") is None +# --------------------------------------------------------------------------- +# paths +# --------------------------------------------------------------------------- -def test_registry_get_returns_skill_by_name(tmp_path, app): - _write_skill(tmp_path, "console-commands", "Artisan console.") - skill = SkillRegistry(app).get("console-commands") - assert skill is not None - assert skill.name == "console-commands" - assert skill.provider_key == "fastapi-startkit" +def test_base_path_property(app, tmp_path): + assert SkillRegistry(app).base_path == tmp_path -def test_registry_skills_base_path_property(tmp_path, app): - r = SkillRegistry(app) - assert r.skills_base_path == tmp_path / ".ai" / "fastapi-startkit" +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() From 4be02313b148fa6d9f5da3c083efbbc36134049d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 23:41:13 -0700 Subject: [PATCH 2/2] test(skills): drive ai:skills via Cleo CommandTester, drop DatabaseProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify the skills command/registry tests: - Register only the real FastAPIProvider (declares the `fastapi` skill). DatabaseProvider was pulled in purely for a second skill and forced a global Model/Migrator.db_manager save/restore fixture for no added coverage — both are gone. - Drive SkillsCommand through Cleo's CommandTester instead of hand-stubbing option/line/info/choice/confirm. Option parsing and IO now go through the real machinery; tests run non-interactively so an omitted --target resolves to the prompt default ("all") rather than blocking. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/skills/test_commands.py | 159 +++++++----------- .../tests/skills/test_registry.py | 61 +++---- 2 files changed, 88 insertions(+), 132 deletions(-) diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py index 36e4a1a7..6bfd104d 100644 --- a/fastapi_startkit/tests/skills/test_commands.py +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -1,53 +1,46 @@ """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 framework's -real ``FastAPIProvider`` (``fastapi``) and ``DatabaseProvider`` (``database``) -declare the bundled skills, so the tests register the actual providers rather -than stubs. ``AISkillProvider`` binds the registry the command resolves. +``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.container.container import Container -from fastapi_startkit.masoniteorm import DatabaseProvider, Migrator, Model 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(autouse=True) -def restore_db_manager(): - """DatabaseProvider.register() sets these class globals (absent by default). - Snapshot and restore so registering it here can't leak into other tests.""" - _MISSING = object() - saved = {cls: getattr(cls, "db_manager", _MISSING) for cls in (Model, Migrator)} - yield - for cls, value in saved.items(): - if value is _MISSING: - if hasattr(cls, "db_manager"): - delattr(cls, "db_manager") - else: - cls.db_manager = value - - @pytest.fixture def app(tmp_path): - """App with the real skill-declaring providers and a bound registry.""" + """App with the real skill-declaring FastAPIProvider and a bound registry.""" return Application( base_path=tmp_path, env="testing", - providers=[AISkillProvider, FastAPIProvider, DatabaseProvider], + providers=[AISkillProvider, FastAPIProvider], ) @@ -57,29 +50,13 @@ def empty_app(tmp_path): return Application(base_path=tmp_path, env="testing", providers=[AISkillProvider]) -def _run(cmd_class, container, args=None, choice="all"): - """Instantiate a command, stub its Cleo I/O, and run handle(). - - Returns ``(exit_code, lines)`` where ``lines`` is everything written via - ``line``/``info``. - """ - cmd = cmd_class() - cmd.set_container(container) - - opts = {} - for arg in args or []: - if arg.startswith("--"): - k = arg.lstrip("-").split("=")[0] - v = arg.split("=")[1] if "=" in arg else True - opts[k] = v - - lines = [] - cmd.option = lambda k, default=None: opts.get(k, default) - cmd.line = lambda msg, *a, **kw: lines.append(msg) - cmd.info = lambda msg, *a, **kw: lines.append(msg) - cmd.choice = lambda *a, **kw: choice - cmd.confirm = lambda *a, **kw: False - return cmd.handle(), lines +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 # =========================================================================== @@ -89,23 +66,19 @@ def _run(cmd_class, container, args=None, choice="all"): class TestSkillsListCommand: def test_list_shows_declaring_providers(self, app): - code, lines = _run(SkillsCommand, app, ["--list"]) - assert code == 0 - out = "\n".join(lines) - assert "fastapi" in out - assert "database" in out + 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): - code, lines = _run(SkillsCommand, app) - assert code == 0 - out = "\n".join(lines) - assert "fastapi" in out - assert "database" in out + 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): - code, lines = _run(SkillsCommand, empty_app, ["--list"]) - assert code == 0 - assert any("No skills" in l for l in lines) + 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" @@ -117,57 +90,55 @@ def test_command_name(self): class TestSkillsSyncCommand: - def test_sync_claude_publishes_stubs_and_skill_files(self, app, tmp_path): - code, _ = _run(SkillsCommand, app, ["--sync", "--target=claude"]) - assert code == 0 - # Stubs are copied into the project's .ai/ tree. + 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() - assert (tmp_path / ".ai" / "fastapi-startkit" / "database" / "SKILL.md").exists() # ClaudeAdapter writes one dir per skill name (from stub front-matter). - assert (tmp_path / ".claude" / "skills" / "fastapi-startkit" / "SKILL.md").exists() - assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() + 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): - code, _ = _run(SkillsCommand, app, ["--sync", "--target=gemini"]) - assert code == 0 + 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): - code, _ = _run(SkillsCommand, app, ["--sync", "--target=all"]) - assert code == 0 - assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() + 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; target is prompted, - # and our stubbed choice returns "all". - code, _ = _run(SkillsCommand, app, ["--prune"]) - assert code == 0 - assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() + # --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): - code, _ = _run(SkillsCommand, app, ["--target=claude"]) - assert code == 0 - assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() - - def test_sync_prompts_for_target_when_omitted(self, app, tmp_path): - code, _ = _run(SkillsCommand, app, ["--sync"], choice="claude") - assert code == 0 - assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() - assert not (tmp_path / "GEMINI.md").exists() + 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): - code, lines = _run(SkillsCommand, app, ["--sync", "--target=codex"]) + tester = run(app, "--sync --target=codex") # Unknown target is reported as a message, not a non-zero exit. - assert code == 0 - assert any("Unknown target" in l for l in lines) + assert tester.status_code == 0 + assert "Unknown target" in tester.io.fetch_output() def test_sync_no_skills_exits_gracefully(self, empty_app): - code, lines = _run(SkillsCommand, empty_app, ["--sync", "--target=claude"]) - assert code == 0 - assert any("No skills" in l for l in lines) + 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. @@ -175,16 +146,16 @@ def test_sync_prune_removes_stale_skill(self, app, tmp_path): stale.mkdir(parents=True) (stale / "SKILL.md").write_text("stale") - _run(SkillsCommand, app, ["--sync", "--target=claude", "--prune"]) + run(app, "--sync --target=claude --prune") assert not stale.exists() - assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").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(SkillsCommand, app, ["--sync", "--target=claude", "--force"]) + run(app, "--sync --target=claude --force") assert "user edited stub" not in dest.read_text() diff --git a/fastapi_startkit/tests/skills/test_registry.py b/fastapi_startkit/tests/skills/test_registry.py index 995efe2c..9e935afa 100644 --- a/fastapi_startkit/tests/skills/test_registry.py +++ b/fastapi_startkit/tests/skills/test_registry.py @@ -1,9 +1,11 @@ """Tests for the provider-driven SkillRegistry. A provider "declares" skills when its ``provider_key`` is a key in -:attr:`SkillRegistry.skills`. The framework ships a matching ``SKILL.md`` stub -for each declared destination; :meth:`SkillRegistry.discover` prefers a project -copy under ``.ai/`` and falls back to the bundled stub. +: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 @@ -12,10 +14,13 @@ from fastapi_startkit.application import Application from fastapi_startkit.container.container import Container -from fastapi_startkit.masoniteorm import DatabaseProvider, Migrator, Model 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(): @@ -24,28 +29,13 @@ def restore_container(): Container._instance = original -@pytest.fixture(autouse=True) -def restore_db_manager(): - """DatabaseProvider.register() sets these class globals (absent by default). - Snapshot and restore so registering it here can't leak into other tests.""" - _MISSING = object() - saved = {cls: getattr(cls, "db_manager", _MISSING) for cls in (Model, Migrator)} - yield - for cls, value in saved.items(): - if value is _MISSING: - if hasattr(cls, "db_manager"): - delattr(cls, "db_manager") - else: - cls.db_manager = value - - @pytest.fixture def app(tmp_path): - """App with the real fastapi + database skill-declaring providers.""" + """App with a skill-declaring provider (fastapi) and a non-declaring one.""" return Application( base_path=tmp_path, env="testing", - providers=[FastAPIProvider, DatabaseProvider], + providers=[AISkillProvider, FastAPIProvider], ) @@ -59,9 +49,9 @@ def empty_app(tmp_path): # --------------------------------------------------------------------------- -def test_get_providers_returns_declaring_keys(app): - keys = set(SkillRegistry(app).get_providers()) - assert keys == {"fastapi", "database"} +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): @@ -73,11 +63,11 @@ def test_get_providers_empty_when_none_declare(empty_app): # --------------------------------------------------------------------------- -def test_discover_loads_skills_from_bundled_stubs(app): +def test_discover_loads_skill_from_bundled_stub(app): skills = SkillRegistry(app).discover() - # Names come from each stub's front-matter. - assert {s.name for s in skills} == {"fastapi-startkit", "database"} - assert {s.provider_key for s in skills} == {"fastapi", "database"} + # 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): @@ -91,12 +81,8 @@ def test_discover_prefers_project_copy_over_stub(app, tmp_path): dest.write_text("---\nname: my-fastapi\ndescription: edited\n---\nbody\n") skills = SkillRegistry(app).discover() - names = {s.name for s in skills} - assert "my-fastapi" in names - assert "fastapi-startkit" not in names - - edited = next(s for s in skills if s.name == "my-fastapi") - assert edited.path == dest + assert {s.name for s in skills} == {"my-fastapi"} + assert skills[0].path == dest # --------------------------------------------------------------------------- @@ -104,11 +90,10 @@ def test_discover_prefers_project_copy_over_stub(app, tmp_path): # --------------------------------------------------------------------------- -def test_publish_writes_stubs_and_renders_claude(app, tmp_path): +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-startkit" / "SKILL.md").exists() - assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() + assert (tmp_path / ".claude" / "skills" / FASTAPI_SKILL / "SKILL.md").exists() assert any("Syncing" in m for m in messages) @@ -124,7 +109,7 @@ def test_publish_unknown_target_returns_message(app): def test_publish_all_writes_both(app, tmp_path): SkillRegistry(app).publish(target="all") - assert (tmp_path / ".claude" / "skills" / "database" / "SKILL.md").exists() + assert (tmp_path / ".claude" / "skills" / FASTAPI_SKILL / "SKILL.md").exists() assert (tmp_path / "GEMINI.md").exists()