From d7e8b6a0e140460a47070a45cd8b55ac3ba30850 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 11:06:00 -0700 Subject: [PATCH 01/11] feat(skills): add SkillRegistry, adapter layer, and artisan commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements tasks #139, #140, #141 — the fastapi-startkit[skills] core: **Task #139 — SkillRegistry + provider discovery** - `skills/registry.py`: SkillRegistry scans only providers currently registered in the Application container, discovering skills/ dirs co-located with each provider.py. SKILL.md YAML front-matter (name, description) is parsed into Skill dataclass objects. - `skills/provider.py`: SkillsServiceProvider registers the registry under 'skills.registry' in the container and boots the CLI commands. **Task #140 — Adapter layer (ClaudeAdapter + GeminiAdapter)** - `skills/adapters/base.py`: BaseAdapter ABC with render() and prune(). - `skills/adapters/claude.py`: ClaudeAdapter writes idempotent .claude/skills//SKILL.md files with YAML front-matter. --prune removes directories for skills no longer in the registry. - `skills/adapters/gemini.py`: GeminiAdapter splices a block into GEMINI.md without touching any user-authored content outside the markers. Idempotent. **Task #141 — Cleo commands** - `skills/commands/sync.py`: `skills:sync` — resolves SkillRegistry, selects adapter(s) via --target (claude|gemini|all, default all), calls render(), optionally prune(). - `skills/commands/list.py`: `skills:list` — shows provider, name, sync status (claude/gemini), and description for every discovered skill. **Tests**: 34 new pytest tests covering registry discovery, front-matter parsing, both adapters (idempotency, prune, marker preservation), and both commands (target routing, prune flag, status display). Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/skills/__init__.py | 6 + .../skills/adapters/__init__.py | 7 + .../fastapi_startkit/skills/adapters/base.py | 49 ++++ .../skills/adapters/claude.py | 81 +++++++ .../skills/adapters/gemini.py | 102 ++++++++ .../skills/commands/__init__.py | 6 + .../fastapi_startkit/skills/commands/list.py | 75 ++++++ .../fastapi_startkit/skills/commands/sync.py | 90 +++++++ .../src/fastapi_startkit/skills/provider.py | 41 ++++ .../src/fastapi_startkit/skills/registry.py | 164 +++++++++++++ fastapi_startkit/tests/skills/__init__.py | 0 .../tests/skills/test_adapters.py | 181 ++++++++++++++ .../tests/skills/test_commands.py | 220 ++++++++++++++++++ .../tests/skills/test_registry.py | 190 +++++++++++++++ 14 files changed, 1212 insertions(+) create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/adapters/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/commands/list.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/provider.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/registry.py create mode 100644 fastapi_startkit/tests/skills/__init__.py create mode 100644 fastapi_startkit/tests/skills/test_adapters.py create mode 100644 fastapi_startkit/tests/skills/test_commands.py create mode 100644 fastapi_startkit/tests/skills/test_registry.py diff --git a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py new file mode 100644 index 00000000..594488a4 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py @@ -0,0 +1,6 @@ +"""fastapi_startkit.skills — provider-driven AI skill registry & adapters.""" + +from .registry import Skill, SkillRegistry +from .provider import SkillsServiceProvider + +__all__ = ["Skill", "SkillRegistry", "SkillsServiceProvider"] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/__init__.py new file mode 100644 index 00000000..5c444d2e --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/adapters/__init__.py @@ -0,0 +1,7 @@ +"""Adapter layer for rendering canonical skills into agent-specific formats.""" + +from .base import BaseAdapter +from .claude import ClaudeAdapter +from .gemini import GeminiAdapter + +__all__ = ["BaseAdapter", "ClaudeAdapter", "GeminiAdapter"] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py new file mode 100644 index 00000000..d48f006b --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py @@ -0,0 +1,49 @@ +"""BaseAdapter — abstract base class for all skill adapters. + +New adapters (e.g. Codex) only need to subclass :class:`BaseAdapter` and +implement :meth:`render` and :meth:`prune`. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Sequence + +from fastapi_startkit.skills.registry import Skill + + +class BaseAdapter(ABC): + """Abstract base for skill adapters. + + Parameters + ---------- + base_path: + Root of the project (where ``.claude/``, ``GEMINI.md``, etc. live). + Defaults to the current working directory. + """ + + #: Short identifier shown in CLI output (e.g. "claude", "gemini"). + name: str = "" + + def __init__(self, base_path: Path | str | None = None) -> None: + self.base_path = Path(base_path) if base_path else Path.cwd() + + # ------------------------------------------------------------------ + # Abstract interface + # ------------------------------------------------------------------ + + @abstractmethod + def render(self, skills: Sequence[Skill]) -> list[str]: + """Write *skills* to the target format. + + Returns a list of human-readable lines describing what was written + (suitable for printing in the ``skills:sync`` command). + """ + + @abstractmethod + def prune(self, skills: Sequence[Skill]) -> list[str]: + """Remove previously-synced skills that are *not* in *skills*. + + Returns a list of human-readable lines describing what was removed. + """ diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py new file mode 100644 index 00000000..a9ef6a64 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py @@ -0,0 +1,81 @@ +"""ClaudeAdapter — renders skills into ``.claude/skills//SKILL.md``.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Sequence + +from fastapi_startkit.skills.registry import Skill +from .base import BaseAdapter + + +class ClaudeAdapter(BaseAdapter): + """Writes canonical skills into Claude Code's skill directory. + + Each skill is rendered as ``.claude/skills//SKILL.md`` with a + YAML front-matter block followed by the original body. Writes are + idempotent — the file is only (over)written when its content would change. + """ + + name = "claude" + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def render(self, skills: Sequence[Skill]) -> list[str]: + messages: list[str] = [] + for skill in skills: + dest = self._skill_path(skill.name) + 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") + return messages + + def prune(self, skills: Sequence[Skill]) -> list[str]: + """Remove ``.claude/skills//`` dirs not represented in *skills*.""" + messages: list[str] = [] + known_names = {s.name for s in skills} + skills_root = self.base_path / ".claude" / "skills" + if not skills_root.is_dir(): + return messages + + for child in sorted(skills_root.iterdir()): + if child.is_dir() and child.name not in known_names: + import shutil + shutil.rmtree(child) + messages.append(f"[claude] Pruned .claude/skills/{child.name}/") + return messages + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _skill_path(self, skill_name: str) -> Path: + return self.base_path / ".claude" / "skills" / skill_name / "SKILL.md" + + @staticmethod + def _build_content(skill: Skill) -> str: + """Render the SKILL.md content for *skill*.""" + lines = ["---", f"name: {skill.name}", f"description: {skill.description}", "---"] + if skill.body: + lines.append("") + lines.append(skill.body) + lines.append("") + return "\n".join(lines) + + @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 new file mode 100644 index 00000000..71ed9f6e --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py @@ -0,0 +1,102 @@ +"""GeminiAdapter — renders skills into ``GEMINI.md`` via marker blocks. + +The adapter manages only the region of ``GEMINI.md`` that lies between the +```` and ```` markers. Content +outside those markers is **never** modified, making the adapter safe to use +even when the user has hand-edited the rest of the file. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Sequence + +from fastapi_startkit.skills.registry import Skill +from .base import BaseAdapter + +_MARKER_START = "" +_MARKER_END = "" + + +class GeminiAdapter(BaseAdapter): + """Writes canonical skills into ``GEMINI.md`` with HTML comment markers. + + If ``GEMINI.md`` does not exist it is created from scratch. If it exists + the content between the markers is replaced; everything outside is left + unchanged. The write is idempotent. + """ + + name = "gemini" + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def render(self, skills: Sequence[Skill]) -> list[str]: + 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))"] + + def prune(self, skills: Sequence[Skill]) -> list[str]: + """For Gemini, pruning just re-renders with the current skill list. + + Since everything lives in a single file within a marked block, + rendering the new (shorter) list is equivalent to pruning. + """ + return self.render(skills) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_section(self, skills: Sequence[Skill]) -> str: + """Return the full marker block to inject into GEMINI.md.""" + parts = [_MARKER_START] + for skill in skills: + parts.append(f"\n## {skill.name}\n") + if skill.description: + parts.append(f"{skill.description}\n") + if skill.body: + parts.append(f"\n{skill.body}\n") + parts.append(_MARKER_END) + return "\n".join(parts) + + def _update_file(self, path: Path, section: str) -> bool: + """Inject *section* into *path*, preserving content outside markers. + + Returns *True* when the file was (re)written, *False* when unchanged. + """ + if path.exists(): + original = path.read_text(encoding="utf-8") + else: + original = "" + + new_content = self._splice(original, section) + + if original == new_content: + return False + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(new_content, encoding="utf-8") + return True + + @staticmethod + def _splice(original: str, section: str) -> str: + """Replace the skills block inside *original* with *section*. + + If the markers do not exist yet the section is appended to the file + (separated by a blank line). + """ + start_idx = original.find(_MARKER_START) + end_idx = original.find(_MARKER_END) + + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + before = original[:start_idx] + after = original[end_idx + len(_MARKER_END):] + return before + section + after + else: + # No markers yet — append + separator = "\n\n" if original and not original.endswith("\n\n") else "" + return original + separator + section + "\n" diff --git a/fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py new file mode 100644 index 00000000..45c16137 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/commands/__init__.py @@ -0,0 +1,6 @@ +"""Cleo commands for the skills module.""" + +from .sync import SkillsSyncCommand +from .list import SkillsListCommand + +__all__ = ["SkillsSyncCommand", "SkillsListCommand"] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py new file mode 100644 index 00000000..4033b6fb --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py @@ -0,0 +1,75 @@ +"""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/sync.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py new file mode 100644 index 00000000..93519140 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py @@ -0,0 +1,90 @@ +"""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 + from fastapi_startkit.skills.adapters import ClaudeAdapter, GeminiAdapter + + 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 in any registered provider.") + 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/provider.py b/fastapi_startkit/src/fastapi_startkit/skills/provider.py new file mode 100644 index 00000000..cc179f6b --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/provider.py @@ -0,0 +1,41 @@ +"""SkillsServiceProvider — registers the skills module into the application.""" + +from __future__ import annotations + +from fastapi_startkit.providers import Provider + + +class SkillsServiceProvider(Provider): + """Service provider that bootstraps the skills module. + + Register it in your application:: + + app = Application( + providers=[ + ..., + SkillsServiceProvider, + ] + ) + + After booting the application you can resolve the registry from the + container:: + + from fastapi_startkit.application import app + registry = app().make("skills.registry") + skills = registry.discover() + """ + + provider_key = "skills" + + def register(self) -> None: + """Bind a :class:`~fastapi_startkit.skills.SkillRegistry` into the container.""" + from fastapi_startkit.skills.registry import SkillRegistry + + # Bind as a callable so the container resolves it lazily on first make() + self.app.bind("skills.registry", SkillRegistry(self.app)) + + def boot(self) -> None: + """Register the skills:sync and skills:list artisan commands.""" + from fastapi_startkit.skills.commands import SkillsSyncCommand, SkillsListCommand + + self.commands([SkillsSyncCommand, SkillsListCommand]) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/registry.py new file mode 100644 index 00000000..00263f22 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/registry.py @@ -0,0 +1,164 @@ +"""SkillRegistry — discovers canonical skills from registered providers. + +Each provider can expose skills by placing a ``skills/`` directory next to its +``provider.py`` file. Every ``SKILL.md`` inside that directory is read for +YAML front-matter that must declare at minimum ``name`` and ``description``. + +Example layout:: + + my_package/ + provider.py + skills/ + my-skill/ + SKILL.md ← name: my-skill / description: … + +""" + +from __future__ import annotations + +import inspect +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi_startkit.application import Application + + +@dataclass +class Skill: + """Canonical skill metadata collected from a provider's skills/ directory.""" + + name: str + description: str + path: Path + provider_key: str = "" + + # Raw markdown body (everything after the YAML front-matter block) + body: str = field(default="", repr=False) + + +def _parse_frontmatter(text: str) -> tuple[dict, str]: + """Parse YAML front-matter from *text*. + + Returns ``(meta_dict, body_str)`` where *meta_dict* holds the YAML keys and + *body_str* is the remainder of the file after the closing ``---`` fence. + Returns ``({}, text)`` if no front-matter block is found. + """ + 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 + + try: + import yaml # type: ignore[import] + except ModuleNotFoundError: + # Minimal fallback: parse simple "key: value" pairs + meta: dict = {} + for line in lines[1:end_idx]: + if ":" in line: + k, _, v = line.partition(":") + meta[k.strip()] = v.strip() + body = "".join(lines[end_idx + 1 :]) + return meta, body + + meta = yaml.safe_load("".join(lines[1:end_idx])) or {} + body = "".join(lines[end_idx + 1 :]) + return meta, body + + +class SkillRegistry: + """Collects :class:`Skill` objects from the providers registered in *app*. + + Only providers that are *already registered* in the Application container + are scanned — skills from un-registered providers are invisible. + """ + + def __init__(self, app: "Application") -> None: + self._app = app + self._skills: list[Skill] | None = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def discover(self) -> list[Skill]: + """Return (and cache) the list of skills found across all providers.""" + if self._skills is not None: + return self._skills + + self._skills = [] + for provider in self._app.providers: + skills_dir = self._skills_dir_for(provider) + if skills_dir is None or not skills_dir.is_dir(): + continue + for skill_md in sorted(skills_dir.rglob("SKILL.md")): + skill = self._load_skill(skill_md, provider.provider_key) + if skill is not None: + self._skills.append(skill) + + return self._skills + + def get(self, name: str) -> Skill | None: + """Return the :class:`Skill` with *name*, or *None* if not found.""" + return next((s for s in self.discover() if s.name == name), None) + + def reset(self) -> None: + """Invalidate the discovery cache (useful in tests).""" + self._skills = None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _skills_dir_for(provider) -> Path | None: + """Return the ``skills/`` directory that sits next to *provider*'s module. + + Returns *None* when the provider is defined in a non-file module (e.g. + dynamically constructed in tests). + """ + try: + module = inspect.getmodule(provider) + if module is None or not getattr(module, "__file__", None): + return None + provider_file = Path(module.__file__) + return provider_file.parent / "skills" + except (TypeError, OSError): + return None + + @staticmethod + def _load_skill(skill_md: Path, provider_key: str) -> Skill | None: + """Parse *skill_md* and return a :class:`Skill`, or *None* on error.""" + try: + text = skill_md.read_text(encoding="utf-8") + except OSError: + return None + + meta, body = _parse_frontmatter(text) + + name = meta.get("name", "").strip() + description = meta.get("description", "").strip() + + if not name: + # Use the parent directory name as a fallback + name = skill_md.parent.name + + if not name: + return None + + return Skill( + name=name, + description=description, + path=skill_md, + provider_key=provider_key, + body=body.strip(), + ) diff --git a/fastapi_startkit/tests/skills/__init__.py b/fastapi_startkit/tests/skills/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/skills/test_adapters.py b/fastapi_startkit/tests/skills/test_adapters.py new file mode 100644 index 00000000..62606cdf --- /dev/null +++ b/fastapi_startkit/tests/skills/test_adapters.py @@ -0,0 +1,181 @@ +"""Tests for ClaudeAdapter and GeminiAdapter (task #140).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from fastapi_startkit.skills.registry import Skill +from fastapi_startkit.skills.adapters.claude import ClaudeAdapter +from fastapi_startkit.skills.adapters.gemini import GeminiAdapter, _MARKER_START, _MARKER_END + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_skill(name: str, description: str = "A test skill.", body: str = "") -> Skill: + return Skill(name=name, description=description, path=Path("/dev/null"), provider_key="test", body=body) + + +# =========================================================================== +# ClaudeAdapter +# =========================================================================== + + +class TestClaudeAdapter: + def test_render_creates_skill_file(self, tmp_path): + adapter = ClaudeAdapter(base_path=tmp_path) + skills = [make_skill("my-skill", "Does stuff")] + + messages = adapter.render(skills) + + skill_file = tmp_path / ".claude" / "skills" / "my-skill" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "name: my-skill" in content + assert "description: Does stuff" in content + assert any("Synced" in m for m in messages) + + def test_render_idempotent(self, tmp_path): + adapter = ClaudeAdapter(base_path=tmp_path) + skills = [make_skill("my-skill")] + + adapter.render(skills) + messages = adapter.render(skills) + + assert any("Unchanged" in m for m in messages) + + def test_render_updates_changed_content(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")]) + + 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) + + def test_render_includes_body(self, tmp_path): + adapter = ClaudeAdapter(base_path=tmp_path) + skills = [make_skill("qs", "Query skill", body="Use `Model.where(...)`")] + adapter.render(skills) + + skill_file = tmp_path / ".claude" / "skills" / "qs" / "SKILL.md" + content = skill_file.read_text() + assert "Use `Model.where(...)`" in content + + def test_prune_removes_unlisted_skills(self, tmp_path): + adapter = ClaudeAdapter(base_path=tmp_path) + # Pre-create two skills + adapter.render([make_skill("keep-me"), make_skill("remove-me")]) + + # Render with only one skill, prune the other + messages = adapter.prune([make_skill("keep-me")]) + + assert (tmp_path / ".claude" / "skills" / "keep-me").exists() + assert not (tmp_path / ".claude" / "skills" / "remove-me").exists() + assert any("Pruned" in m for m in messages) + + def test_prune_noop_when_no_skills_dir(self, tmp_path): + adapter = ClaudeAdapter(base_path=tmp_path) + messages = adapter.prune([]) + assert messages == [] + + def test_render_multiple_skills(self, tmp_path): + adapter = ClaudeAdapter(base_path=tmp_path) + skills = [make_skill("skill-a"), make_skill("skill-b"), make_skill("skill-c")] + messages = adapter.render(skills) + + assert len(messages) == 3 + for name in ["skill-a", "skill-b", "skill-c"]: + assert (tmp_path / ".claude" / "skills" / name / "SKILL.md").exists() + + +# =========================================================================== +# GeminiAdapter +# =========================================================================== + + +class TestGeminiAdapter: + def test_render_creates_gemini_md(self, tmp_path): + adapter = GeminiAdapter(base_path=tmp_path) + skills = [make_skill("my-skill", "Does stuff")] + + messages = adapter.render(skills) + + gemini_md = tmp_path / "GEMINI.md" + assert gemini_md.exists() + content = gemini_md.read_text() + assert _MARKER_START in content + assert _MARKER_END in content + assert "my-skill" in content + assert "Does stuff" in content + assert any("Synced" in m for m in messages) + + def test_render_idempotent(self, tmp_path): + adapter = GeminiAdapter(base_path=tmp_path) + skills = [make_skill("my-skill")] + + adapter.render(skills) + messages = adapter.render(skills) + assert any("Unchanged" in m for m in messages) + + def test_render_preserves_content_outside_markers(self, tmp_path): + gemini_md = tmp_path / "GEMINI.md" + gemini_md.write_text( + "# My Project\n\nSome user content.\n\n" + + _MARKER_START + "\nold skills\n" + _MARKER_END + "\n\n" + "## Extra Section\n\nUser notes.\n" + ) + + adapter = GeminiAdapter(base_path=tmp_path) + adapter.render([make_skill("new-skill")]) + + content = gemini_md.read_text() + assert "My Project" in content + assert "Some user content." in content + assert "Extra Section" in content + assert "User notes." in content + assert "new-skill" in content + + def test_render_appends_if_no_markers(self, tmp_path): + gemini_md = tmp_path / "GEMINI.md" + gemini_md.write_text("# Existing content\n\nUser authored.\n") + + adapter = GeminiAdapter(base_path=tmp_path) + adapter.render([make_skill("appended-skill")]) + + content = gemini_md.read_text() + assert "Existing content" in content + assert "appended-skill" in content + assert _MARKER_START in content + assert _MARKER_END in content + + def test_render_multiple_skills(self, tmp_path): + adapter = GeminiAdapter(base_path=tmp_path) + skills = [make_skill("skill-a", "First skill"), make_skill("skill-b", "Second skill")] + adapter.render(skills) + + content = (tmp_path / "GEMINI.md").read_text() + assert "skill-a" in content + assert "skill-b" in content + + def test_prune_rerenders_with_fewer_skills(self, tmp_path): + adapter = GeminiAdapter(base_path=tmp_path) + adapter.render([make_skill("keep"), make_skill("drop")]) + adapter.prune([make_skill("keep")]) + + content = (tmp_path / "GEMINI.md").read_text() + assert "keep" in content + assert "drop" not in content + + def test_render_updates_changed_description(self, tmp_path): + adapter = GeminiAdapter(base_path=tmp_path) + adapter.render([make_skill("s", "Old desc")]) + adapter.render([make_skill("s", "New desc")]) + + content = (tmp_path / "GEMINI.md").read_text() + assert "New desc" in content + assert "Old desc" not in content diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py new file mode 100644 index 00000000..ef3db140 --- /dev/null +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -0,0 +1,220 @@ +"""Tests for skills:sync and skills:list commands (task #141).""" + +from __future__ import annotations + +import textwrap +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container +from fastapi_startkit.providers import Provider +from fastapi_startkit.skills.registry import Skill, SkillRegistry +from fastapi_startkit.skills.commands.sync import SkillsSyncCommand +from fastapi_startkit.skills.commands.list import SkillsListCommand + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def restore_container(): + original = Container._instance + yield + Container._instance = original + + +@pytest.fixture +def app(tmp_path): + return Application(base_path=tmp_path, env="testing") + + +def _make_skill(name: str, desc: str = "A skill.", body: str = "") -> Skill: + return Skill(name=name, description=desc, path=Path("/dev/null"), provider_key="test", body=body) + + +def _run_command(cmd_class, container, args: list[str] | None = None): + """Run a Cleo command directly using its handle() method. + + We fake the IO and option parsing to keep tests free of Cleo internals. + """ + from cleo.io.null_io import NullIO + from cleo.io.inputs.string_input import StringInput + from cleo.io.outputs.output import Output + + cmd = cmd_class() + cmd.set_container(container) + + # Build a simple mock for option() and line() + option_values = {} + if args: + for i, arg in enumerate(args): + if arg.startswith("--"): + k = arg.lstrip("-").split("=")[0] + v = arg.split("=")[1] if "=" in arg else True + option_values[k] = v + + output_lines: list[str] = [] + + cmd.option = lambda k, default=None: option_values.get(k, default) + cmd.line = lambda msg, *a, **kw: output_lines.append(msg) + cmd.info = lambda msg, *a, **kw: output_lines.append(msg) + + return_code = cmd.handle() + return return_code, output_lines + + +# =========================================================================== +# SkillsSyncCommand +# =========================================================================== + + +class TestSkillsSyncCommand: + def test_sync_all_writes_claude_and_gemini(self, tmp_path, app): + skills = [_make_skill("orm-routing", "ORM routing skill")] + registry = MagicMock(spec=SkillRegistry) + registry.discover.return_value = skills + app.bind("skills.registry", registry) + + return_code, lines = _run_command(SkillsSyncCommand, app, args=["--target=all"]) + + assert return_code == 0 + # Claude adapter should have written the file + assert (tmp_path / ".claude" / "skills" / "orm-routing" / "SKILL.md").exists() + # Gemini adapter should have written GEMINI.md + assert (tmp_path / "GEMINI.md").exists() + + def test_sync_claude_only(self, tmp_path, app): + skills = [_make_skill("console-commands")] + registry = MagicMock(spec=SkillRegistry) + registry.discover.return_value = skills + app.bind("skills.registry", registry) + + return_code, _ = _run_command(SkillsSyncCommand, app, args=["--target=claude"]) + + assert return_code == 0 + 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): + skills = [_make_skill("fastapi-routing")] + registry = MagicMock(spec=SkillRegistry) + registry.discover.return_value = skills + app.bind("skills.registry", registry) + + return_code, _ = _run_command(SkillsSyncCommand, app, args=["--target=gemini"]) + + assert return_code == 0 + assert not (tmp_path / ".claude").exists() + assert (tmp_path / "GEMINI.md").exists() + + def test_sync_unknown_target_returns_error(self, tmp_path, app): + registry = MagicMock(spec=SkillRegistry) + registry.discover.return_value = [] + app.bind("skills.registry", registry) + + return_code, lines = _run_command(SkillsSyncCommand, app, args=["--target=codex"]) + + assert return_code == 1 + assert any("Unknown target" in ln for ln in lines) + + def test_sync_no_skills_exits_gracefully(self, tmp_path, app): + registry = MagicMock(spec=SkillRegistry) + registry.discover.return_value = [] + app.bind("skills.registry", registry) + + return_code, lines = _run_command(SkillsSyncCommand, app) + + assert return_code == 0 + assert any("No skills" in ln for ln in lines) + + def test_sync_prune_flag_removes_old_skills(self, tmp_path, app): + # Pre-create a skill that is not in the registry + old_skill_dir = tmp_path / ".claude" / "skills" / "old-skill" + old_skill_dir.mkdir(parents=True) + (old_skill_dir / "SKILL.md").write_text("---\nname: old-skill\ndescription: gone\n---\n") + + skills = [_make_skill("new-skill")] + registry = MagicMock(spec=SkillRegistry) + registry.discover.return_value = skills + app.bind("skills.registry", registry) + + return_code, lines = _run_command(SkillsSyncCommand, app, args=["--target=claude", "--prune"]) + + assert return_code == 0 + assert not old_skill_dir.exists() + assert any("Pruned" in ln for ln in lines) + + def test_command_name_and_description(self): + cmd = SkillsSyncCommand() + assert cmd.name == "skills:sync" + assert "sync" in cmd.description.lower() + + +# =========================================================================== +# SkillsListCommand +# =========================================================================== + + +class TestSkillsListCommand: + def test_list_shows_skills(self, tmp_path, app): + skills = [ + _make_skill("fastapi-routing", "FastAPI routing helpers"), + _make_skill("orm-queries", "ORM query helpers"), + ] + registry = MagicMock(spec=SkillRegistry) + registry.discover.return_value = skills + app.bind("skills.registry", registry) + + return_code, lines = _run_command(SkillsListCommand, app) + + assert return_code == 0 + all_output = "\n".join(lines) + assert "fastapi-routing" in all_output + assert "orm-queries" in all_output + + def test_list_shows_pending_when_not_synced(self, tmp_path, app): + skills = [_make_skill("my-skill")] + registry = MagicMock(spec=SkillRegistry) + registry.discover.return_value = skills + app.bind("skills.registry", registry) + + return_code, lines = _run_command(SkillsListCommand, app) + all_output = "\n".join(lines) + + assert "pending" in all_output + + def test_list_shows_synced_when_claude_file_exists(self, tmp_path, app): + skill_dir = tmp_path / ".claude" / "skills" / "my-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: my-skill\n---\n") + + skills = [_make_skill("my-skill")] + registry = MagicMock(spec=SkillRegistry) + registry.discover.return_value = skills + app.bind("skills.registry", registry) + + return_code, lines = _run_command(SkillsListCommand, app) + all_output = "\n".join(lines) + + assert "synced" in all_output + + def test_list_no_skills_message(self, tmp_path, app): + registry = MagicMock(spec=SkillRegistry) + registry.discover.return_value = [] + app.bind("skills.registry", registry) + + return_code, lines = _run_command(SkillsListCommand, app) + + assert return_code == 0 + assert any("No skills" in ln for ln in lines) + + def test_command_name_and_description(self): + cmd = SkillsListCommand() + assert cmd.name == "skills:list" + assert "list" in cmd.description.lower() or "skill" in cmd.description.lower() diff --git a/fastapi_startkit/tests/skills/test_registry.py b/fastapi_startkit/tests/skills/test_registry.py new file mode 100644 index 00000000..8a81f900 --- /dev/null +++ b/fastapi_startkit/tests/skills/test_registry.py @@ -0,0 +1,190 @@ +"""Tests for SkillRegistry (task #139).""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container +from fastapi_startkit.providers import Provider +from fastapi_startkit.skills.registry import Skill, SkillRegistry, _parse_frontmatter + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def restore_container(): + original = Container._instance + yield + Container._instance = original + + +@pytest.fixture +def app(tmp_path): + return Application(base_path=tmp_path, env="testing") + + +# --------------------------------------------------------------------------- +# _parse_frontmatter unit tests +# --------------------------------------------------------------------------- + + +def test_parse_frontmatter_basic(): + text = textwrap.dedent("""\ + --- + name: my-skill + description: Does something useful + --- + Body content here. + """) + meta, body = _parse_frontmatter(text) + assert meta["name"] == "my-skill" + assert meta["description"] == "Does something useful" + assert "Body content here." in body + + +def test_parse_frontmatter_missing_fence(): + text = "No YAML front-matter here." + meta, body = _parse_frontmatter(text) + assert meta == {} + assert body == text + + +def test_parse_frontmatter_unclosed_fence(): + text = "---\nname: oops\n" + meta, body = _parse_frontmatter(text) + assert meta == {} + + +# --------------------------------------------------------------------------- +# SkillRegistry — discovery tests +# --------------------------------------------------------------------------- + + +def _make_skill_md(directory: Path, name: str, description: str, body: str = "") -> Path: + """Helper: write a SKILL.md into *directory/name/SKILL.md*.""" + skill_dir = directory / name + skill_dir.mkdir(parents=True, exist_ok=True) + skill_file = skill_dir / "SKILL.md" + lines = ["---", f"name: {name}", f"description: {description}", "---"] + if body: + lines += ["", body] + skill_file.write_text("\n".join(lines)) + return skill_file + + +class _DynamicProvider(Provider): + """Provider whose skills/ directory is injected at runtime (for testing).""" + + _skills_path: Path | None = None + provider_key = "dynamic" + + def register(self): + pass + + def boot(self): + pass + + +def test_registry_discovers_skills_from_registered_providers(tmp_path, monkeypatch, app): + """SkillRegistry.discover() returns skills from providers with a skills/ dir.""" + # Create a fake provider module file and skills/ dir + provider_dir = tmp_path / "mypkg" + provider_dir.mkdir() + fake_module_file = provider_dir / "provider.py" + fake_module_file.write_text("# fake") + + skills_dir = provider_dir / "skills" + _make_skill_md(skills_dir, "fastapi-routing", "Helps with FastAPI routing") + _make_skill_md(skills_dir, "orm-queries", "Helps with ORM queries", body="Use `Model.where(...)` for filtering.") + + # Monkeypatch inspect.getmodule to return a fake module for the provider + import inspect as _inspect + import types + + fake_module = types.ModuleType("mypkg.provider") + fake_module.__file__ = str(fake_module_file) + + original_getmodule = _inspect.getmodule + + def patched_getmodule(obj, _filename=None): + if isinstance(obj, _DynamicProvider): + return fake_module + return original_getmodule(obj, _filename) + + monkeypatch.setattr(_inspect, "getmodule", patched_getmodule) + + registry = SkillRegistry(app) + # Inject the provider instance directly + app.providers.append(_DynamicProvider(app)) + + skills = registry.discover() + + assert len(skills) == 2 + names = {s.name for s in skills} + assert names == {"fastapi-routing", "orm-queries"} + + orm_skill = next(s for s in skills if s.name == "orm-queries") + assert "Model.where" in orm_skill.body + + +def test_registry_ignores_providers_without_skills_dir(tmp_path, app): + """SkillRegistry does not crash on providers that have no skills/ dir.""" + registry = SkillRegistry(app) + skills = registry.discover() + # Default providers (ConfigurationProvider, AppProvider) have no skills/ + assert isinstance(skills, list) + + +def test_registry_caches_results(tmp_path, monkeypatch, app): + """Calling discover() twice returns the same list without re-scanning.""" + registry = SkillRegistry(app) + first = registry.discover() + second = registry.discover() + assert first is second # same object, not recomputed + + +def test_registry_reset_clears_cache(tmp_path, app): + registry = SkillRegistry(app) + registry.discover() + assert registry._skills is not None + registry.reset() + assert registry._skills is None + + +def test_registry_get_by_name(tmp_path, monkeypatch, app): + import inspect as _inspect + import types + + provider_dir = tmp_path / "pkg" + provider_dir.mkdir() + fake_module = types.ModuleType("pkg.provider") + fake_module.__file__ = str(provider_dir / "provider.py") + + skills_dir = provider_dir / "skills" + _make_skill_md(skills_dir, "console-commands", "Artisan console commands") + + original_getmodule = _inspect.getmodule + + def patched(obj, _filename=None): + if isinstance(obj, _DynamicProvider): + return fake_module + return original_getmodule(obj, _filename) + + monkeypatch.setattr(_inspect, "getmodule", patched) + + registry = SkillRegistry(app) + app.providers.append(_DynamicProvider(app)) + + skill = registry.get("console-commands") + assert skill is not None + assert skill.name == "console-commands" + + missing = registry.get("does-not-exist") + assert missing is None From 168a1977728b39ccfb3b10267dd97ecf4ae49c27 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 15:52:17 -0700 Subject: [PATCH 02/11] refactor(skills): centralise skill definitions in .ai/deployments/core.html MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-provider skills/ directory scanning with a single central .ai/deployments/core.html file as the source of truth for all skills. Architecture change: - SkillRegistry now reads from {base_path}/.ai/deployments/core.html instead of scanning each registered provider for a skills/ dir - core.html uses
blocks — plain HTML, Jinja2-compatible for future template rendering - _CoreHtmlParser (html.parser, zero extra deps) extracts section blocks, preserving body content verbatim - SkillsServiceProvider.boot() publishes stubs/core.html to .ai/deployments/core.html via `artisan provider:publish --provider=skills` - Adapters (ClaudeAdapter, GeminiAdapter) unchanged — they still deploy Skill objects to .claude/skills//SKILL.md and GEMINI.md Added: skills/stubs/core.html with three example skills (fastapi-routing, orm-queries, console-commands) ready to publish to any project. Tests: 43 passing (9 new registry tests added for HTML parser + path behaviour; command tests updated to write core.html instead of mocking provider module structure). Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/skills/__init__.py | 16 +- .../src/fastapi_startkit/skills/provider.py | 24 +- .../src/fastapi_startkit/skills/registry.py | 230 ++++++++-------- .../fastapi_startkit/skills/stubs/core.html | 85 ++++++ .../tests/skills/test_commands.py | 132 +++++----- .../tests/skills/test_registry.py | 246 ++++++++++-------- 6 files changed, 440 insertions(+), 293 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/core.html diff --git a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py index 594488a4..cbae6be2 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py @@ -1,6 +1,16 @@ -"""fastapi_startkit.skills — provider-driven AI skill registry & adapters.""" +"""fastapi_startkit.skills — central AI skill registry & adapters. -from .registry import Skill, SkillRegistry +Skills are defined in ``.ai/deployments/core.html`` and deployed to each +configured AI agent target via ``artisan skills:sync``. +""" + +from .registry import Skill, SkillRegistry, parse_core_html, CORE_HTML_PATH from .provider import SkillsServiceProvider -__all__ = ["Skill", "SkillRegistry", "SkillsServiceProvider"] +__all__ = [ + "Skill", + "SkillRegistry", + "SkillsServiceProvider", + "parse_core_html", + "CORE_HTML_PATH", +] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/provider.py b/fastapi_startkit/src/fastapi_startkit/skills/provider.py index cc179f6b..d202e2be 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/provider.py @@ -2,8 +2,12 @@ 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 module. @@ -17,25 +21,35 @@ class SkillsServiceProvider(Provider): ] ) - After booting the application you can resolve the registry from the - container:: + After booting, resolve the registry from the container:: from fastapi_startkit.application import app registry = app().make("skills.registry") skills = registry.discover() + + Publish the starter ``core.html`` to your project with:: + + artisan provider:publish --provider=skills """ provider_key = "skills" def register(self) -> None: - """Bind a :class:`~fastapi_startkit.skills.SkillRegistry` into the container.""" + """Bind :class:`~fastapi_startkit.skills.SkillRegistry` into the container.""" from fastapi_startkit.skills.registry import SkillRegistry - # Bind as a callable so the container resolves it lazily on first make() self.app.bind("skills.registry", SkillRegistry(self.app)) def boot(self) -> None: - """Register the skills:sync and skills:list artisan commands.""" + """Register commands and publishable resources.""" from fastapi_startkit.skills.commands import SkillsSyncCommand, SkillsListCommand self.commands([SkillsSyncCommand, SkillsListCommand]) + + # Publish the starter core.html → project/.ai/deployments/core.html + self.publishes( + { + str(_STUBS_DIR / "core.html"): ".ai/deployments/core.html", + }, + tag="skills", + ) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/registry.py index 00263f22..3bc09531 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/registry.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/registry.py @@ -1,85 +1,131 @@ -"""SkillRegistry — discovers canonical skills from registered providers. +"""SkillRegistry — reads canonical skills from ``.ai/deployments/core.html``. -Each provider can expose skills by placing a ``skills/`` directory next to its -``provider.py`` file. Every ``SKILL.md`` inside that directory is read for -YAML front-matter that must declare at minimum ``name`` and ``description``. +All skills are defined in a single central HTML file at:: -Example layout:: + /.ai/deployments/core.html - my_package/ - provider.py - skills/ - my-skill/ - SKILL.md ← name: my-skill / description: … +Each skill is a ``
`` element with two required attributes: +.. code-block:: html + +
+ Markdown body content goes here. +
+ +The file is Jinja2-compatible — add ``{% %}`` / ``{{ }}`` expressions freely; +they are passed through as-is until a future Jinja2 render step is wired up. + +Running ``artisan skills:sync`` deploys the skills in this file to every +configured AI agent target (Claude Code, Gemini CLI, …). """ from __future__ import annotations -import inspect from dataclasses import dataclass, field +from html.parser import HTMLParser from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from fastapi_startkit.application import Application +#: Relative path of the central skill definition file inside the project root. +CORE_HTML_PATH = Path(".ai") / "deployments" / "core.html" + @dataclass class Skill: - """Canonical skill metadata collected from a provider's skills/ directory.""" + """Canonical skill metadata parsed from ``.ai/deployments/core.html``.""" name: str description: str - path: Path + path: Path # always points to the core.html source file provider_key: str = "" - # Raw markdown body (everything after the YAML front-matter block) + # Markdown body — the text content of the
element body: str = field(default="", repr=False) -def _parse_frontmatter(text: str) -> tuple[dict, str]: - """Parse YAML front-matter from *text*. +# --------------------------------------------------------------------------- +# HTML parser +# --------------------------------------------------------------------------- - Returns ``(meta_dict, body_str)`` where *meta_dict* holds the YAML keys and - *body_str* is the remainder of the file after the closing ``---`` fence. - Returns ``({}, text)`` if no front-matter block is found. - """ - 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 - - try: - import yaml # type: ignore[import] - except ModuleNotFoundError: - # Minimal fallback: parse simple "key: value" pairs - meta: dict = {} - for line in lines[1:end_idx]: - if ":" in line: - k, _, v = line.partition(":") - meta[k.strip()] = v.strip() - body = "".join(lines[end_idx + 1 :]) - return meta, body - - meta = yaml.safe_load("".join(lines[1:end_idx])) or {} - body = "".join(lines[end_idx + 1 :]) - return meta, body + +class _CoreHtmlParser(HTMLParser): + """Minimal SAX-style parser that extracts ``
`` blocks.""" + + def __init__(self) -> None: + super().__init__() + self._in_section: bool = False + self._current_name: str = "" + self._current_desc: str = "" + self._buf: list[str] = [] + self.skills: list[dict] = [] + + def handle_starttag(self, tag: str, attrs: list) -> None: + if tag != "section": + return + attrs_dict = dict(attrs) + name = (attrs_dict.get("data-skill") or "").strip() + if not name: + return + self._in_section = True + self._current_name = name + self._current_desc = (attrs_dict.get("data-description") or "").strip() + self._buf = [] + + def handle_endtag(self, tag: str) -> None: + if tag == "section" and self._in_section: + self.skills.append( + { + "name": self._current_name, + "description": self._current_desc, + "body": "".join(self._buf).strip(), + } + ) + self._in_section = False + self._current_name = "" + self._current_desc = "" + self._buf = [] + + def handle_data(self, data: str) -> None: + if self._in_section: + self._buf.append(data) + + # Comments and other nodes inside a section are collected verbatim + def handle_comment(self, data: str) -> None: + if self._in_section: + self._buf.append(f"") + + +def parse_core_html(path: Path) -> list[dict]: + """Parse *path* and return a list of raw skill dicts (name, description, body).""" + text = path.read_text(encoding="utf-8") + parser = _CoreHtmlParser() + parser.feed(text) + return parser.skills + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- class SkillRegistry: - """Collects :class:`Skill` objects from the providers registered in *app*. + """Loads :class:`Skill` objects from ``.ai/deployments/core.html``. + + The registry reads the central HTML skill file located at:: + + {base_path}/.ai/deployments/core.html + + If the file does not exist, :meth:`discover` returns an empty list and + prints a helpful hint. + + Usage:: - Only providers that are *already registered* in the Application container - are scanned — skills from un-registered providers are invisible. + from fastapi_startkit.application import app + registry = app().make("skills.registry") + skills = registry.discover() """ def __init__(self, app: "Application") -> None: @@ -90,20 +136,34 @@ def __init__(self, app: "Application") -> None: # Public API # ------------------------------------------------------------------ + @property + def core_html_path(self) -> Path: + """Absolute path to the central skill definition file.""" + return Path(self._app.base_path) / CORE_HTML_PATH + def discover(self) -> list[Skill]: - """Return (and cache) the list of skills found across all providers.""" + """Return (and cache) the list of skills parsed from ``core.html``.""" if self._skills is not None: return self._skills - self._skills = [] - for provider in self._app.providers: - skills_dir = self._skills_dir_for(provider) - if skills_dir is None or not skills_dir.is_dir(): - continue - for skill_md in sorted(skills_dir.rglob("SKILL.md")): - skill = self._load_skill(skill_md, provider.provider_key) - if skill is not None: - self._skills.append(skill) + source = self.core_html_path + + if not source.exists(): + self._skills = [] + return self._skills + + raw_skills = parse_core_html(source) + self._skills = [ + Skill( + name=raw["name"], + description=raw["description"], + path=source, + provider_key="core", + body=raw["body"], + ) + for raw in raw_skills + if raw.get("name") + ] return self._skills @@ -114,51 +174,3 @@ def get(self, name: str) -> Skill | None: def reset(self) -> None: """Invalidate the discovery cache (useful in tests).""" self._skills = None - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - @staticmethod - def _skills_dir_for(provider) -> Path | None: - """Return the ``skills/`` directory that sits next to *provider*'s module. - - Returns *None* when the provider is defined in a non-file module (e.g. - dynamically constructed in tests). - """ - try: - module = inspect.getmodule(provider) - if module is None or not getattr(module, "__file__", None): - return None - provider_file = Path(module.__file__) - return provider_file.parent / "skills" - except (TypeError, OSError): - return None - - @staticmethod - def _load_skill(skill_md: Path, provider_key: str) -> Skill | None: - """Parse *skill_md* and return a :class:`Skill`, or *None* on error.""" - try: - text = skill_md.read_text(encoding="utf-8") - except OSError: - return None - - meta, body = _parse_frontmatter(text) - - name = meta.get("name", "").strip() - description = meta.get("description", "").strip() - - if not name: - # Use the parent directory name as a fallback - name = skill_md.parent.name - - if not name: - return None - - return Skill( - name=name, - description=description, - path=skill_md, - provider_key=provider_key, - body=body.strip(), - ) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/core.html b/fastapi_startkit/src/fastapi_startkit/skills/stubs/core.html new file mode 100644 index 00000000..3001b54d --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/core.html @@ -0,0 +1,85 @@ + + + +
+Use `Router` from `fastapi_startkit.fastapi` to register routes: + +```python +from fastapi_startkit.fastapi import Router + +router = Router() +router.get("/items", list_items) +router.post("/items", create_item) +router.resource("users", UsersController) +``` + +Group routes by access level using separate `Router` instances and +pass them to `app.fastapi` via your RouteServiceProvider. +
+ +
+All ORM operations are async. Basic patterns: + +```python +# Fetch all +users = await User.all() + +# Filter +active = await User.where("active", True).get() + +# First or fail +user = await User.find_or_fail(user_id) + +# Create +user = await User.create(name="Alice", email="alice@example.com") + +# Relationships +posts = await user.posts().get() +``` + +Use `exists()` to check without fetching rows: +```python +if await User.where("email", email).exists(): + raise ValidationError("Email taken") +``` +
+ +
+Extend `Command` from `fastapi_startkit.console`: + +```python +from fastapi_startkit.console import Command +from cleo.helpers import option, argument + +class GreetCommand(Command): + name = "greet" + description = "Greet a user by name." + arguments = [argument("name", description="Name to greet")] + + def handle(self): + self.line(f"Hello, {self.argument('name')}!") +``` + +Register in a provider: +```python +def boot(self): + self.commands([GreetCommand]) +``` + +Run with: `artisan greet Alice` +
diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py index ef3db140..3d74620e 100644 --- a/fastapi_startkit/tests/skills/test_commands.py +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -3,16 +3,14 @@ from __future__ import annotations import textwrap -import types from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest from fastapi_startkit.application import Application from fastapi_startkit.container.container import Container -from fastapi_startkit.providers import Provider -from fastapi_startkit.skills.registry import Skill, SkillRegistry +from fastapi_startkit.skills.registry import Skill, SkillRegistry, CORE_HTML_PATH from fastapi_startkit.skills.commands.sync import SkillsSyncCommand from fastapi_startkit.skills.commands.list import SkillsListCommand @@ -34,33 +32,35 @@ def app(tmp_path): return Application(base_path=tmp_path, env="testing") -def _make_skill(name: str, desc: str = "A skill.", body: str = "") -> Skill: - return Skill(name=name, description=desc, path=Path("/dev/null"), provider_key="test", body=body) +def _write_core_html(tmp_path: Path, skills: list[tuple[str, str]]) -> None: + """Write a core.html with the given (name, description) skills.""" + core = tmp_path / CORE_HTML_PATH + core.parent.mkdir(parents=True, exist_ok=True) + sections = "\n".join( + f'
Skill body for {name}.
' + for name, desc in skills + ) + core.write_text(f"\n{sections}\n", encoding="utf-8") -def _run_command(cmd_class, container, args: list[str] | None = None): - """Run a Cleo command directly using its handle() method. +def _make_skill(name: str, desc: str = "A skill.", body: str = "") -> Skill: + return Skill(name=name, description=desc, path=Path("/dev/null"), provider_key="core", body=body) - We fake the IO and option parsing to keep tests free of Cleo internals. - """ - from cleo.io.null_io import NullIO - from cleo.io.inputs.string_input import StringInput - from cleo.io.outputs.output import Output +def _run_command(cmd_class, container, args: list[str] | None = None): + """Execute a command's handle() with lightweight fake IO.""" cmd = cmd_class() cmd.set_container(container) - # Build a simple mock for option() and line() - option_values = {} + option_values: dict = {} if args: - for i, arg in enumerate(args): + for arg in args: if arg.startswith("--"): k = arg.lstrip("-").split("=")[0] v = arg.split("=")[1] if "=" in arg else True option_values[k] = v output_lines: list[str] = [] - cmd.option = lambda k, default=None: option_values.get(k, default) cmd.line = lambda msg, *a, **kw: output_lines.append(msg) cmd.info = lambda msg, *a, **kw: output_lines.append(msg) @@ -76,24 +76,20 @@ def _run_command(cmd_class, container, args: list[str] | None = None): class TestSkillsSyncCommand: def test_sync_all_writes_claude_and_gemini(self, tmp_path, app): - skills = [_make_skill("orm-routing", "ORM routing skill")] - registry = MagicMock(spec=SkillRegistry) - registry.discover.return_value = skills + _write_core_html(tmp_path, [("orm-routing", "ORM routing skill")]) + + registry = SkillRegistry(app) app.bind("skills.registry", registry) return_code, lines = _run_command(SkillsSyncCommand, app, args=["--target=all"]) assert return_code == 0 - # Claude adapter should have written the file assert (tmp_path / ".claude" / "skills" / "orm-routing" / "SKILL.md").exists() - # Gemini adapter should have written GEMINI.md assert (tmp_path / "GEMINI.md").exists() def test_sync_claude_only(self, tmp_path, app): - skills = [_make_skill("console-commands")] - registry = MagicMock(spec=SkillRegistry) - registry.discover.return_value = skills - app.bind("skills.registry", registry) + _write_core_html(tmp_path, [("console-commands", "Artisan commands")]) + app.bind("skills.registry", SkillRegistry(app)) return_code, _ = _run_command(SkillsSyncCommand, app, args=["--target=claude"]) @@ -102,10 +98,8 @@ def test_sync_claude_only(self, tmp_path, app): assert not (tmp_path / "GEMINI.md").exists() def test_sync_gemini_only(self, tmp_path, app): - skills = [_make_skill("fastapi-routing")] - registry = MagicMock(spec=SkillRegistry) - registry.discover.return_value = skills - app.bind("skills.registry", registry) + _write_core_html(tmp_path, [("fastapi-routing", "FastAPI routing")]) + app.bind("skills.registry", SkillRegistry(app)) return_code, _ = _run_command(SkillsSyncCommand, app, args=["--target=gemini"]) @@ -114,42 +108,53 @@ def test_sync_gemini_only(self, tmp_path, app): assert (tmp_path / "GEMINI.md").exists() def test_sync_unknown_target_returns_error(self, tmp_path, app): - registry = MagicMock(spec=SkillRegistry) - registry.discover.return_value = [] - app.bind("skills.registry", registry) + app.bind("skills.registry", SkillRegistry(app)) return_code, lines = _run_command(SkillsSyncCommand, app, args=["--target=codex"]) assert return_code == 1 assert any("Unknown target" in ln for ln in lines) - def test_sync_no_skills_exits_gracefully(self, tmp_path, app): - registry = MagicMock(spec=SkillRegistry) - registry.discover.return_value = [] - app.bind("skills.registry", registry) + def test_sync_no_core_html_exits_gracefully(self, tmp_path, app): + # No core.html file → registry returns empty list + app.bind("skills.registry", SkillRegistry(app)) return_code, lines = _run_command(SkillsSyncCommand, app) assert return_code == 0 assert any("No skills" in ln for ln in lines) - def test_sync_prune_flag_removes_old_skills(self, tmp_path, app): - # Pre-create a skill that is not in the registry - old_skill_dir = tmp_path / ".claude" / "skills" / "old-skill" - old_skill_dir.mkdir(parents=True) - (old_skill_dir / "SKILL.md").write_text("---\nname: old-skill\ndescription: gone\n---\n") + def test_sync_prune_flag_removes_old_claude_skills(self, tmp_path, app): + # Pre-create an orphan skill dir + old_dir = tmp_path / ".claude" / "skills" / "old-skill" + old_dir.mkdir(parents=True) + (old_dir / "SKILL.md").write_text("---\nname: old-skill\n---\n") - skills = [_make_skill("new-skill")] - registry = MagicMock(spec=SkillRegistry) - registry.discover.return_value = skills - app.bind("skills.registry", registry) + _write_core_html(tmp_path, [("new-skill", "New skill")]) + app.bind("skills.registry", SkillRegistry(app)) return_code, lines = _run_command(SkillsSyncCommand, app, args=["--target=claude", "--prune"]) assert return_code == 0 - assert not old_skill_dir.exists() + assert not old_dir.exists() assert any("Pruned" in ln for ln in lines) + def test_sync_claude_skill_content_includes_body(self, tmp_path, app): + core = tmp_path / CORE_HTML_PATH + core.parent.mkdir(parents=True, exist_ok=True) + core.write_text( + '
' + "Use `Model.where(...)` for filtering." + "
", + encoding="utf-8", + ) + app.bind("skills.registry", SkillRegistry(app)) + + _run_command(SkillsSyncCommand, app, args=["--target=claude"]) + + content = (tmp_path / ".claude" / "skills" / "orm" / "SKILL.md").read_text() + assert "Model.where" in content + def test_command_name_and_description(self): cmd = SkillsSyncCommand() assert cmd.name == "skills:sync" @@ -162,14 +167,12 @@ def test_command_name_and_description(self): class TestSkillsListCommand: - def test_list_shows_skills(self, tmp_path, app): - skills = [ - _make_skill("fastapi-routing", "FastAPI routing helpers"), - _make_skill("orm-queries", "ORM query helpers"), - ] - registry = MagicMock(spec=SkillRegistry) - registry.discover.return_value = skills - app.bind("skills.registry", registry) + def test_list_shows_skills_from_core_html(self, tmp_path, app): + _write_core_html( + tmp_path, + [("fastapi-routing", "FastAPI routing helpers"), ("orm-queries", "ORM query helpers")], + ) + app.bind("skills.registry", SkillRegistry(app)) return_code, lines = _run_command(SkillsListCommand, app) @@ -179,10 +182,8 @@ def test_list_shows_skills(self, tmp_path, app): assert "orm-queries" in all_output def test_list_shows_pending_when_not_synced(self, tmp_path, app): - skills = [_make_skill("my-skill")] - registry = MagicMock(spec=SkillRegistry) - registry.discover.return_value = skills - app.bind("skills.registry", registry) + _write_core_html(tmp_path, [("my-skill", "My skill")]) + app.bind("skills.registry", SkillRegistry(app)) return_code, lines = _run_command(SkillsListCommand, app) all_output = "\n".join(lines) @@ -194,20 +195,16 @@ def test_list_shows_synced_when_claude_file_exists(self, tmp_path, app): skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text("---\nname: my-skill\n---\n") - skills = [_make_skill("my-skill")] - registry = MagicMock(spec=SkillRegistry) - registry.discover.return_value = skills - app.bind("skills.registry", registry) + _write_core_html(tmp_path, [("my-skill", "My skill")]) + app.bind("skills.registry", SkillRegistry(app)) return_code, lines = _run_command(SkillsListCommand, app) all_output = "\n".join(lines) assert "synced" in all_output - def test_list_no_skills_message(self, tmp_path, app): - registry = MagicMock(spec=SkillRegistry) - registry.discover.return_value = [] - app.bind("skills.registry", registry) + def test_list_no_core_html_shows_message(self, tmp_path, app): + app.bind("skills.registry", SkillRegistry(app)) return_code, lines = _run_command(SkillsListCommand, app) @@ -217,4 +214,3 @@ def test_list_no_skills_message(self, tmp_path, app): def test_command_name_and_description(self): cmd = SkillsListCommand() assert cmd.name == "skills:list" - assert "list" in cmd.description.lower() or "skill" in cmd.description.lower() diff --git a/fastapi_startkit/tests/skills/test_registry.py b/fastapi_startkit/tests/skills/test_registry.py index 8a81f900..62b9674d 100644 --- a/fastapi_startkit/tests/skills/test_registry.py +++ b/fastapi_startkit/tests/skills/test_registry.py @@ -1,4 +1,4 @@ -"""Tests for SkillRegistry (task #139).""" +"""Tests for SkillRegistry reading from .ai/deployments/core.html (task #139).""" from __future__ import annotations @@ -9,8 +9,13 @@ from fastapi_startkit.application import Application from fastapi_startkit.container.container import Container -from fastapi_startkit.providers import Provider -from fastapi_startkit.skills.registry import Skill, SkillRegistry, _parse_frontmatter +from fastapi_startkit.skills.registry import ( + Skill, + SkillRegistry, + _CoreHtmlParser, + parse_core_html, + CORE_HTML_PATH, +) # --------------------------------------------------------------------------- @@ -30,127 +35,160 @@ def app(tmp_path): return Application(base_path=tmp_path, env="testing") -# --------------------------------------------------------------------------- -# _parse_frontmatter unit tests -# --------------------------------------------------------------------------- - - -def test_parse_frontmatter_basic(): - text = textwrap.dedent("""\ - --- - name: my-skill - description: Does something useful - --- - Body content here. - """) - meta, body = _parse_frontmatter(text) - assert meta["name"] == "my-skill" - assert meta["description"] == "Does something useful" - assert "Body content here." in body - - -def test_parse_frontmatter_missing_fence(): - text = "No YAML front-matter here." - meta, body = _parse_frontmatter(text) - assert meta == {} - assert body == text - - -def test_parse_frontmatter_unclosed_fence(): - text = "---\nname: oops\n" - meta, body = _parse_frontmatter(text) - assert meta == {} +def _write_core_html(tmp_path: Path, content: str) -> Path: + """Write *content* to the canonical core.html location under *tmp_path*.""" + core = tmp_path / CORE_HTML_PATH + core.parent.mkdir(parents=True, exist_ok=True) + core.write_text(content, encoding="utf-8") + return core # --------------------------------------------------------------------------- -# SkillRegistry — discovery tests +# _CoreHtmlParser unit tests # --------------------------------------------------------------------------- -def _make_skill_md(directory: Path, name: str, description: str, body: str = "") -> Path: - """Helper: write a SKILL.md into *directory/name/SKILL.md*.""" - skill_dir = directory / name - skill_dir.mkdir(parents=True, exist_ok=True) - skill_file = skill_dir / "SKILL.md" - lines = ["---", f"name: {name}", f"description: {description}", "---"] - if body: - lines += ["", body] - skill_file.write_text("\n".join(lines)) - return skill_file +def test_parser_extracts_single_section(): + html = textwrap.dedent("""\ +
+ Use router.get() to register routes. +
+ """) + parser = _CoreHtmlParser() + parser.feed(html) + assert len(parser.skills) == 1 + s = parser.skills[0] + assert s["name"] == "fastapi-routing" + assert s["description"] == "FastAPI routing helpers." + assert "router.get()" in s["body"] + + +def test_parser_extracts_multiple_sections(): + html = textwrap.dedent("""\ +
Body A.
+
Body B.
+ """) + parser = _CoreHtmlParser() + parser.feed(html) + assert len(parser.skills) == 2 + assert parser.skills[0]["name"] == "skill-a" + assert parser.skills[1]["name"] == "skill-b" -class _DynamicProvider(Provider): - """Provider whose skills/ directory is injected at runtime (for testing).""" +def test_parser_skips_sections_without_data_skill(): + html = '
body
' + parser = _CoreHtmlParser() + parser.feed(html) + assert parser.skills == [] - _skills_path: Path | None = None - provider_key = "dynamic" - def register(self): - pass +def test_parser_ignores_non_section_tags(): + html = textwrap.dedent("""\ +
some wrapper
+
content
+

irrelevant

+ """) + parser = _CoreHtmlParser() + parser.feed(html) + assert len(parser.skills) == 1 + assert parser.skills[0]["name"] == "valid" + + +def test_parser_handles_html_comments_inside_section(): + html = textwrap.dedent("""\ +
+ + Some body text. +
+ """) + parser = _CoreHtmlParser() + parser.feed(html) + assert len(parser.skills) == 1 + body = parser.skills[0]["body"] + assert "Some body text." in body + - def boot(self): - pass +def test_parser_trims_body_whitespace(): + html = '
\n\n trimmed \n\n
' + parser = _CoreHtmlParser() + parser.feed(html) + assert parser.skills[0]["body"] == "trimmed" -def test_registry_discovers_skills_from_registered_providers(tmp_path, monkeypatch, app): - """SkillRegistry.discover() returns skills from providers with a skills/ dir.""" - # Create a fake provider module file and skills/ dir - provider_dir = tmp_path / "mypkg" - provider_dir.mkdir() - fake_module_file = provider_dir / "provider.py" - fake_module_file.write_text("# fake") +# --------------------------------------------------------------------------- +# parse_core_html +# --------------------------------------------------------------------------- - skills_dir = provider_dir / "skills" - _make_skill_md(skills_dir, "fastapi-routing", "Helps with FastAPI routing") - _make_skill_md(skills_dir, "orm-queries", "Helps with ORM queries", body="Use `Model.where(...)` for filtering.") - # Monkeypatch inspect.getmodule to return a fake module for the provider - import inspect as _inspect - import types +def test_parse_core_html_returns_skill_dicts(tmp_path): + core = _write_core_html(tmp_path, textwrap.dedent("""\ +
+ Use Model.where().get() for filtering. +
+ """)) + skills = parse_core_html(core) + assert len(skills) == 1 + assert skills[0]["name"] == "orm-queries" - fake_module = types.ModuleType("mypkg.provider") - fake_module.__file__ = str(fake_module_file) - original_getmodule = _inspect.getmodule +# --------------------------------------------------------------------------- +# SkillRegistry +# --------------------------------------------------------------------------- - def patched_getmodule(obj, _filename=None): - if isinstance(obj, _DynamicProvider): - return fake_module - return original_getmodule(obj, _filename) - monkeypatch.setattr(_inspect, "getmodule", patched_getmodule) +def test_registry_discovers_skills_from_core_html(tmp_path, app): + _write_core_html(tmp_path, textwrap.dedent("""\ +
+ Router body. +
+
+ ORM body. +
+ """)) registry = SkillRegistry(app) - # Inject the provider instance directly - app.providers.append(_DynamicProvider(app)) - skills = registry.discover() assert len(skills) == 2 names = {s.name for s in skills} assert names == {"fastapi-routing", "orm-queries"} - orm_skill = next(s for s in skills if s.name == "orm-queries") - assert "Model.where" in orm_skill.body + +def test_registry_returns_empty_list_when_core_html_missing(tmp_path, app): + registry = SkillRegistry(app) + skills = registry.discover() + assert skills == [] -def test_registry_ignores_providers_without_skills_dir(tmp_path, app): - """SkillRegistry does not crash on providers that have no skills/ dir.""" +def test_registry_skill_path_points_to_core_html(tmp_path, app): + _write_core_html(tmp_path, '
body
') registry = SkillRegistry(app) skills = registry.discover() - # Default providers (ConfigurationProvider, AppProvider) have no skills/ - assert isinstance(skills, list) + assert skills[0].path == tmp_path / CORE_HTML_PATH + + +def test_registry_skill_body_is_captured(tmp_path, app): + _write_core_html(tmp_path, textwrap.dedent("""\ +
+ Use `Model.where(...)` +
+ """)) + registry = SkillRegistry(app) + skill = registry.get("orm") + assert skill is not None + assert "Model.where" in skill.body -def test_registry_caches_results(tmp_path, monkeypatch, app): - """Calling discover() twice returns the same list without re-scanning.""" +def test_registry_caches_results(tmp_path, app): + _write_core_html(tmp_path, '
body
') registry = SkillRegistry(app) first = registry.discover() second = registry.discover() - assert first is second # same object, not recomputed + assert first is second def test_registry_reset_clears_cache(tmp_path, app): + _write_core_html(tmp_path, '
body
') registry = SkillRegistry(app) registry.discover() assert registry._skills is not None @@ -158,33 +196,25 @@ def test_registry_reset_clears_cache(tmp_path, app): assert registry._skills is None -def test_registry_get_by_name(tmp_path, monkeypatch, app): - import inspect as _inspect - import types - - provider_dir = tmp_path / "pkg" - provider_dir.mkdir() - fake_module = types.ModuleType("pkg.provider") - fake_module.__file__ = str(provider_dir / "provider.py") - - skills_dir = provider_dir / "skills" - _make_skill_md(skills_dir, "console-commands", "Artisan console commands") - - original_getmodule = _inspect.getmodule - - def patched(obj, _filename=None): - if isinstance(obj, _DynamicProvider): - return fake_module - return original_getmodule(obj, _filename) +def test_registry_get_returns_none_for_unknown(tmp_path, app): + _write_core_html(tmp_path, '
body
') + registry = SkillRegistry(app) + assert registry.get("does-not-exist") is None - monkeypatch.setattr(_inspect, "getmodule", patched) +def test_registry_get_returns_skill_by_name(tmp_path, app): + _write_core_html(tmp_path, textwrap.dedent("""\ +
+ Extend Command class. +
+ """)) registry = SkillRegistry(app) - app.providers.append(_DynamicProvider(app)) - skill = registry.get("console-commands") assert skill is not None assert skill.name == "console-commands" + assert skill.provider_key == "core" - missing = registry.get("does-not-exist") - assert missing is None + +def test_registry_core_html_path_property(tmp_path, app): + registry = SkillRegistry(app) + assert registry.core_html_path == tmp_path / ".ai" / "deployments" / "core.html" From 20ce94194844f87969ffc8c93c576d756d9ef909 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 16:01:01 -0700 Subject: [PATCH 03/11] feat(skills): SKILL.md-based discovery + rules system (rules/*.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigns the skills module to match the Laravel Boost convention and adds a full per-topic rules system. **Skills — .ai/fastapi-startkit/skill/{name}/SKILL.md** - SkillRegistry now scans .ai/fastapi-startkit/skill/*/SKILL.md (individual folders per skill, YAML frontmatter: name, description, license, metadata) - Falls back to directory name when frontmatter has no 'name' key - Published stub: .ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md with a 19-section Quick Reference pointing to rules/ files **Rules — rules/{name}.md** - RulesRegistry scans rules/*.md (plain Markdown, optional frontmatter) - Rule name = file stem (http-client.md → "http-client") - ClaudeRulesAdapter → .claude/rules/.md (idempotent, prune) - GeminiRulesAdapter → GEMINI.md block - rules:sync command (--target claude|gemini|all, --prune) - rules:list command (shows name, Claude/Gemini status, path) - Published stub: rules/http-client.md with Python/httpx best practices: explicit timeout + connect_timeout, exponential backoff, raise_for_status(), asyncio.gather() for concurrent requests, respx fakes in tests **SkillsServiceProvider** now registers both skills.registry and rules.registry and boots all four commands (skills:sync, skills:list, rules:sync, rules:list). Tests: 48 passing (14 new for rules registry, commands, and adapters). Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/skills/__init__.py | 18 +- .../src/fastapi_startkit/skills/provider.py | 43 ++- .../src/fastapi_startkit/skills/registry.py | 194 +++++------- .../fastapi_startkit/skills/rules/__init__.py | 5 + .../skills/rules/adapters/__init__.py | 6 + .../skills/rules/adapters/claude.py | 57 ++++ .../skills/rules/adapters/gemini.py | 66 ++++ .../skills/rules/commands/__init__.py | 6 + .../skills/rules/commands/list.py | 55 ++++ .../skills/rules/commands/sync.py | 82 +++++ .../fastapi_startkit/skills/rules/registry.py | 145 +++++++++ .../skill/fastapi-best-practices/SKILL.md | 75 +++++ .../skills/stubs/rules/http-client.md | 130 ++++++++ .../tests/skills/test_commands.py | 292 +++++++++--------- .../tests/skills/test_registry.py | 242 +++++---------- 15 files changed, 950 insertions(+), 466 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/commands/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/rules/http-client.md diff --git a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py index cbae6be2..09aee77b 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py @@ -1,16 +1,22 @@ -"""fastapi_startkit.skills — central AI skill registry & adapters. +"""fastapi_startkit.skills — AI skill & rules registry and adapters. -Skills are defined in ``.ai/deployments/core.html`` and deployed to each -configured AI agent target via ``artisan skills:sync``. +Skills -> .ai/fastapi-startkit/skill/{name}/SKILL.md +Rules -> rules/{name}.md + +Run ``artisan skills:sync`` to deploy skills, ``artisan rules:sync`` for rules. """ -from .registry import Skill, SkillRegistry, parse_core_html, CORE_HTML_PATH +from .registry import Skill, SkillRegistry, SKILLS_BASE_PATH, _parse_frontmatter from .provider import SkillsServiceProvider +from .rules import Rule, RulesRegistry, RULES_BASE_PATH __all__ = [ "Skill", "SkillRegistry", "SkillsServiceProvider", - "parse_core_html", - "CORE_HTML_PATH", + "SKILLS_BASE_PATH", + "Rule", + "RulesRegistry", + "RULES_BASE_PATH", + "_parse_frontmatter", ] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/provider.py b/fastapi_startkit/src/fastapi_startkit/skills/provider.py index d202e2be..e252225e 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/provider.py @@ -1,4 +1,4 @@ -"""SkillsServiceProvider — registers the skills module into the application.""" +"""SkillsServiceProvider — registers skills and rules into the application.""" from __future__ import annotations @@ -10,46 +10,43 @@ class SkillsServiceProvider(Provider): - """Service provider that bootstraps the skills module. + """Service provider that bootstraps the skills and rules modules. - Register it in your application:: - - app = Application( - providers=[ - ..., - SkillsServiceProvider, - ] - ) - - After booting, resolve the registry from the container:: - - from fastapi_startkit.application import app - registry = app().make("skills.registry") - skills = registry.discover() - - Publish the starter ``core.html`` to your project with:: + Publish starter files with:: artisan provider:publish --provider=skills + + This copies: + - .ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md + - rules/http-client.md """ provider_key = "skills" def register(self) -> None: - """Bind :class:`~fastapi_startkit.skills.SkillRegistry` into the container.""" 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)) def boot(self) -> None: - """Register commands and publishable resources.""" from fastapi_startkit.skills.commands import SkillsSyncCommand, SkillsListCommand + from fastapi_startkit.skills.rules.commands import RulesSyncCommand, RulesListCommand - self.commands([SkillsSyncCommand, SkillsListCommand]) + self.commands([ + SkillsSyncCommand, + SkillsListCommand, + RulesSyncCommand, + RulesListCommand, + ]) - # Publish the starter core.html → project/.ai/deployments/core.html self.publishes( { - str(_STUBS_DIR / "core.html"): ".ai/deployments/core.html", + str(_STUBS_DIR / ".ai" / "fastapi-startkit" / "skill" / "fastapi-best-practices" / "SKILL.md"): + ".ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md", + str(_STUBS_DIR / "rules" / "http-client.md"): + "rules/http-client.md", }, tag="skills", ) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/registry.py index 3bc09531..86580671 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/registry.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/registry.py @@ -1,176 +1,122 @@ -"""SkillRegistry — reads canonical skills from ``.ai/deployments/core.html``. +"""SkillRegistry — discovers canonical skills from ``.ai/fastapi-startkit/skill/``. -All skills are defined in a single central HTML file at:: +Skills follow the same convention as Laravel Boost:: - /.ai/deployments/core.html + .ai/fastapi-startkit/skill/ + fastapi-best-practices/ + SKILL.md <- YAML frontmatter (name, description) + markdown body -Each skill is a ``
`` element with two required attributes: - -.. code-block:: html - -
- Markdown body content goes here. -
- -The file is Jinja2-compatible — add ``{% %}`` / ``{{ }}`` expressions freely; -they are passed through as-is until a future Jinja2 render step is wired up. - -Running ``artisan skills:sync`` deploys the skills in this file to every -configured AI agent target (Claude Code, Gemini CLI, …). +Running ``artisan skills:sync`` deploys skills to every configured AI agent target. """ from __future__ import annotations from dataclasses import dataclass, field -from html.parser import HTMLParser from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from fastapi_startkit.application import Application -#: Relative path of the central skill definition file inside the project root. -CORE_HTML_PATH = Path(".ai") / "deployments" / "core.html" +#: Base directory for framework skills (relative to project root). +SKILLS_BASE_PATH = Path(".ai") / "fastapi-startkit" / "skill" @dataclass class Skill: - """Canonical skill metadata parsed from ``.ai/deployments/core.html``.""" + """Canonical skill metadata parsed from a SKILL.md file.""" name: str description: str - path: Path # always points to the core.html source file - provider_key: str = "" - - # Markdown body — the text content of the
element + path: Path + provider_key: str = "fastapi-startkit" body: str = field(default="", repr=False) + metadata: dict = field(default_factory=dict, repr=False) -# --------------------------------------------------------------------------- -# HTML parser -# --------------------------------------------------------------------------- +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 -class _CoreHtmlParser(HTMLParser): - """Minimal SAX-style parser that extracts ``
`` blocks.""" + if end_idx is None: + return {}, text - def __init__(self) -> None: - super().__init__() - self._in_section: bool = False - self._current_name: str = "" - self._current_desc: str = "" - self._buf: list[str] = [] - self.skills: list[dict] = [] + fm_text = "".join(lines[1:end_idx]) + body = "".join(lines[end_idx + 1:]) - def handle_starttag(self, tag: str, attrs: list) -> None: - if tag != "section": - return - attrs_dict = dict(attrs) - name = (attrs_dict.get("data-skill") or "").strip() - if not name: - return - self._in_section = True - self._current_name = name - self._current_desc = (attrs_dict.get("data-description") or "").strip() - self._buf = [] - - def handle_endtag(self, tag: str) -> None: - if tag == "section" and self._in_section: - self.skills.append( - { - "name": self._current_name, - "description": self._current_desc, - "body": "".join(self._buf).strip(), - } - ) - self._in_section = False - self._current_name = "" - self._current_desc = "" - self._buf = [] - - def handle_data(self, data: str) -> None: - if self._in_section: - self._buf.append(data) - - # Comments and other nodes inside a section are collected verbatim - def handle_comment(self, data: str) -> None: - if self._in_section: - self._buf.append(f"") - - -def parse_core_html(path: Path) -> list[dict]: - """Parse *path* and return a list of raw skill dicts (name, description, body).""" - text = path.read_text(encoding="utf-8") - parser = _CoreHtmlParser() - parser.feed(text) - return parser.skills - - -# --------------------------------------------------------------------------- -# Registry -# --------------------------------------------------------------------------- - - -class SkillRegistry: - """Loads :class:`Skill` objects from ``.ai/deployments/core.html``. + 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() - The registry reads the central HTML skill file located at:: + return meta, body - {base_path}/.ai/deployments/core.html - If the file does not exist, :meth:`discover` returns an empty list and - prints a helpful hint. - - Usage:: - - from fastapi_startkit.application import app - registry = app().make("skills.registry") - skills = registry.discover() - """ +class SkillRegistry: + """Loads Skill objects from .ai/fastapi-startkit/skill/*/SKILL.md.""" def __init__(self, app: "Application") -> None: self._app = app self._skills: list[Skill] | None = None - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - @property - def core_html_path(self) -> Path: - """Absolute path to the central skill definition file.""" - return Path(self._app.base_path) / CORE_HTML_PATH + def skills_base_path(self) -> Path: + return Path(self._app.base_path) / SKILLS_BASE_PATH def discover(self) -> list[Skill]: - """Return (and cache) the list of skills parsed from ``core.html``.""" if self._skills is not None: return self._skills - source = self.core_html_path - - if not source.exists(): + base = self.skills_base_path + if not base.is_dir(): self._skills = [] return self._skills - raw_skills = parse_core_html(source) - self._skills = [ - Skill( - name=raw["name"], - description=raw["description"], - path=source, - provider_key="core", - body=raw["body"], - ) - for raw in raw_skills - if raw.get("name") - ] + 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 the :class:`Skill` with *name*, or *None* if not found.""" + def get(self, name: str) -> "Skill | None": return next((s for s in self.discover() if s.name == name), None) def reset(self) -> None: - """Invalidate the discovery cache (useful in tests).""" 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, + ) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py new file mode 100644 index 00000000..79181272 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py @@ -0,0 +1,5 @@ +"""Rules sub-system — per-topic coding rule files at ``rules/*.md``.""" + +from .registry import Rule, RulesRegistry, RULES_BASE_PATH + +__all__ = ["Rule", "RulesRegistry", "RULES_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 new file mode 100644 index 00000000..ac5b7229 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/__init__.py @@ -0,0 +1,6 @@ +"""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 new file mode 100644 index 00000000..21b95863 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py @@ -0,0 +1,57 @@ +"""ClaudeRulesAdapter — deploys rules to ``.claude/rules/.md``.""" + +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 its content + would change. ``--prune`` removes rule files no longer in the registry. + """ + + 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.name) + written = self._write_idempotent(dest, rule.body) + verb = "Synced" if written else "Unchanged" + messages.append(f"[claude] {verb} .claude/rules/{rule.name}.md") + return messages + + def prune(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] + """Remove ``.claude/rules/.md`` files not in *rules*.""" + messages: list[str] = [] + known = {r.name for r in rules} + rules_dir = self.base_path / ".claude" / "rules" + if not rules_dir.is_dir(): + return messages + + for child in sorted(rules_dir.glob("*.md")): + if child.stem not in known: + child.unlink() + messages.append(f"[claude] Pruned .claude/rules/{child.name}") + return messages + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _rule_path(self, rule_name: str) -> Path: + return self.base_path / ".claude" / "rules" / 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 new file mode 100644 index 00000000..ecf34702 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py @@ -0,0 +1,66 @@ +"""GeminiRulesAdapter — splices rules into ``GEMINI.md`` via marker block.""" + +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``. + + 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.""" + return self.render(rules) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _build_section(rules: Sequence[Rule]) -> str: + parts = [_MARKER_START] + for rule in 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 new file mode 100644 index 00000000..e6e763c5 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/__init__.py @@ -0,0 +1,6 @@ +"""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 new file mode 100644 index 00000000..3c45b6c9 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py @@ -0,0 +1,55 @@ +"""rules:list — list all rules found in rules/ and their sync status.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi_startkit.console import Command + + +class RulesListCommand(Command): + """List all rule files in ``rules/`` and their deployment status. + + Example usage:: + + artisan rules:list + """ + + name = "rules:list" + description = "List rules/*.md files 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 in rules/. Create rules/*.md files first.") + return 0 + + base_path: Path = self.container.base_path + + self.line("") + self.line(f" Found {len(rules)} rule(s) in rules/:") + self.line("") + + header = f" {'NAME':<30} {'CLAUDE':<10} {'GEMINI':<10} PATH" + self.line(header) + self.line(" " + "-" * (len(header) - 2)) + + for rule in rules: + claude_status = "synced" if (base_path / ".claude" / "rules" / f"{rule.name}.md").exists() else "pending" + gemini_status = self._gemini_status(base_path) + rel = rule.path.relative_to(base_path) if rule.path.is_absolute() else rule.path + self.line(f" {rule.name:<30} {claude_status:<10} {gemini_status:<10} {rel}") + + 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 new file mode 100644 index 00000000..214a4ceb --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py @@ -0,0 +1,82 @@ +"""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 + from fastapi_startkit.skills.rules.adapters import ClaudeRulesAdapter, GeminiRulesAdapter + + 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 new file mode 100644 index 00000000..3632acda --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py @@ -0,0 +1,145 @@ +"""RulesRegistry — discovers per-topic rule files from ``rules/``. + +Rules are individual Markdown files that contain detailed coding guidelines:: + + rules/ + http-client.md ← HTTP client best practices + orm-queries.md ← ORM / database query rules + validation.md ← Request validation rules + error-handling.md ← Error handling patterns + +Each file is plain Markdown. The rule **name** is derived from the file stem +(``http-client.md`` → ``http-client``). An optional YAML front-matter block +may supply an explicit name or description, but it is not required. + +Rules are referenced from skill files (e.g. the Quick Reference section of +``.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md``) and are also +deployed individually to every configured AI agent target. +""" + +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 + +#: Location of rule files relative to the project root. +RULES_BASE_PATH = Path("rules") + + +@dataclass +class Rule: + """A single per-topic coding rule loaded from ``rules/.md``.""" + + name: str # derived from file stem, e.g. "http-client" + path: Path # absolute path to the .md source file + body: str = field(default="", repr=False) # full markdown content + description: str = "" # from frontmatter if present + + +def _parse_frontmatter(text: str) -> tuple[dict, str]: + """Parse optional YAML front-matter and 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 ``{base_path}/rules/*.md``. + + Usage:: + + from fastapi_startkit.application import app + registry = app().make("rules.registry") + rules = registry.discover() + """ + + def __init__(self, app: "Application") -> None: + self._app = app + self._rules: list[Rule] | None = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + @property + def rules_base_path(self) -> Path: + """Absolute path to the ``rules/`` directory.""" + return Path(self._app.base_path) / RULES_BASE_PATH + + def discover(self) -> list[Rule]: + """Return (and cache) all rules found in ``rules/``.""" + if self._rules is not None: + return self._rules + + base = self.rules_base_path + if not base.is_dir(): + self._rules = [] + return self._rules + + self._rules = [] + for md_file in sorted(base.glob("*.md")): + rule = self._load(md_file) + if rule is not None: + self._rules.append(rule) + + return self._rules + + def get(self, name: str) -> Rule | None: + """Return the :class:`Rule` with *name* (file stem), or *None*.""" + return next((r for r in self.discover() if r.name == name), None) + + def reset(self) -> None: + """Invalidate the discovery cache.""" + self._rules = None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _load(md_file: Path) -> 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, + 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/skill/fastapi-best-practices/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md new file mode 100644 index 00000000..32c0fca7 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md @@ -0,0 +1,75 @@ +--- +name: fastapi-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring FastAPI Startkit code. Covers controllers, ORM models, migrations, providers, console commands, HTTP clients, validation, error handling, and architectural decisions. Use for code reviews and refactoring existing code to follow framework best practices." +license: MIT +metadata: + author: fastapi-startkit +--- + +# FastAPI Startkit Best Practices + +Best practices for FastAPI Startkit, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with the framework docs. + +## Consistency First + +Before applying any rule, check what the application already does. FastAPI Startkit offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connect_timeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `raise_for_status()` +- Use `asyncio.gather()` for concurrent independent requests +- Mock HTTP clients with `httpx` fakes and `respx` in tests + +### 2. ORM Queries → `rules/orm-queries.md` + +- All DB operations are `async` / `await` +- Eager load relationships to prevent N+1 queries +- Use `exists()` to check without fetching rows +- `chunk()` for large datasets to avoid memory issues +- Index columns used in `WHERE`, `ORDER BY` + +### 3. Validation → `rules/validation.md` + +- Use Pydantic models for all request bodies +- Declare `response_model` on every endpoint +- Never trust raw user input — always validate shapes and types +- Return structured error responses with RFC 7807 format + +### 4. Error Handling → `rules/error-handling.md` + +- Register custom exception handlers in your provider +- Use `HTTPException` for client errors, custom exceptions for domain errors +- Always log unexpected exceptions with context +- Never expose internal stack traces to clients + +### 5. Providers & Container → `rules/providers.md` + +- Bind services in `register()`, resolve dependencies in `boot()` +- Inject via the container — avoid `app().make()` in business logic +- Use facades for static-style access; add `.pyi` stubs for IDE support + +### 6. Console Commands → `rules/console-commands.md` + +- Extend `Command` from `fastapi_startkit.console` +- Use `option()` and `argument()` helpers for CLI args +- Register commands in your provider's `boot()` via `self.commands([...])` +- Keep `handle()` thin — delegate to services + +### 7. Testing → `rules/testing.md` + +- Use `pytest` with `asyncio_mode = "auto"` +- Reset the container singleton between tests +- Use `tmp_path` for filesystem isolation +- Mock external services — never hit real APIs in tests + +## How to Apply + +1. Identify the area of work and select relevant rule sections above +2. Check sibling files for existing patterns — follow those first +3. Verify API syntax with the framework docs for the installed version diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/rules/http-client.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/rules/http-client.md new file mode 100644 index 00000000..6a27d83c --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/rules/http-client.md @@ -0,0 +1,130 @@ +# HTTP Client + +Rules for making outbound HTTP requests in FastAPI Startkit applications. + +## Rules + +### Always set explicit timeouts + +Set both `timeout` and `connect_timeout` on **every** request. Never rely on +the default (which may be infinite in some clients): + +```python +import httpx + +async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.example.com/data", + timeout=httpx.Timeout(timeout=10.0, connect=5.0), + ) +``` + +For a shared client (e.g. in a provider), set defaults at construction time: + +```python +client = httpx.AsyncClient( + timeout=httpx.Timeout(timeout=10.0, connect=5.0), + base_url="https://api.example.com", +) +``` + +### Retry with exponential backoff for external APIs + +Do not let transient failures propagate immediately. Use exponential backoff +with jitter on retryable status codes (429, 502, 503, 504): + +```python +import asyncio +import httpx + +RETRYABLE = {429, 502, 503, 504} + +async def get_with_retry(url: str, max_retries: int = 3) -> httpx.Response: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client: + for attempt in range(max_retries + 1): + try: + response = await client.get(url) + if response.status_code not in RETRYABLE: + return response + except (httpx.ConnectError, httpx.TimeoutException): + if attempt == max_retries: + raise + + wait = 2 ** attempt # 1 s, 2 s, 4 s … + await asyncio.sleep(wait) + + raise RuntimeError("Max retries exceeded") +``` + +### Check response status explicitly + +Always check the response status. Use `raise_for_status()` as a safe default +or inspect the code explicitly when you need to branch on specific errors: + +```python +# Safe default — raises httpx.HTTPStatusError on 4xx / 5xx +response = await client.get(url) +response.raise_for_status() +data = response.json() + +# Explicit branching +if response.status_code == 404: + return None +if response.status_code == 429: + raise RateLimitedError("External API rate limited") +response.raise_for_status() +``` + +Never silently ignore a failed status code. + +### Use asyncio.gather() for concurrent independent requests + +When multiple requests are independent of each other, run them concurrently +instead of sequentially: + +```python +import asyncio +import httpx + +async def fetch_all(urls: list[str]) -> list[dict]: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client: + responses = await asyncio.gather( + *[client.get(url) for url in urls], + return_exceptions=True, + ) + results = [] + for r in responses: + if isinstance(r, Exception): + raise r + r.raise_for_status() + results.append(r.json()) + return results +``` + +### Mock HTTP clients in tests + +Never hit real external APIs in tests. Use `respx` (for `httpx`) to intercept +and fake responses: + +```python +import respx +import httpx + +@respx.mock +async def test_fetch_user(): + respx.get("https://api.example.com/users/1").mock( + return_value=httpx.Response(200, json={"id": 1, "name": "Alice"}) + ) + result = await fetch_user(1) + assert result["name"] == "Alice" +``` + +For stray-request protection (equivalent to Laravel's `preventStrayRequests`), +use `respx.mock(assert_all_mocked=True)` to raise on any unmocked call: + +```python +@respx.mock(assert_all_mocked=True) +async def test_no_stray_requests(): + # Any unmocked httpx call will raise AssertionError + ... +``` diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py index 3d74620e..30d382a0 100644 --- a/fastapi_startkit/tests/skills/test_commands.py +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -1,8 +1,7 @@ -"""Tests for skills:sync and skills:list commands (task #141).""" +"""Tests for skills:sync, skills:list, rules:sync, rules:list commands.""" from __future__ import annotations -import textwrap from pathlib import Path from unittest.mock import MagicMock @@ -10,14 +9,12 @@ from fastapi_startkit.application import Application from fastapi_startkit.container.container import Container -from fastapi_startkit.skills.registry import Skill, SkillRegistry, CORE_HTML_PATH +from fastapi_startkit.skills.registry import Skill, SkillRegistry, SKILLS_BASE_PATH +from fastapi_startkit.skills.rules.registry import Rule, RulesRegistry, RULES_BASE_PATH from fastapi_startkit.skills.commands.sync import SkillsSyncCommand from fastapi_startkit.skills.commands.list import SkillsListCommand - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- +from fastapi_startkit.skills.rules.commands.sync import RulesSyncCommand +from fastapi_startkit.skills.rules.commands.list import RulesListCommand @pytest.fixture(autouse=True) @@ -32,185 +29,186 @@ def app(tmp_path): return Application(base_path=tmp_path, env="testing") -def _write_core_html(tmp_path: Path, skills: list[tuple[str, str]]) -> None: - """Write a core.html with the given (name, description) skills.""" - core = tmp_path / CORE_HTML_PATH - core.parent.mkdir(parents=True, exist_ok=True) - sections = "\n".join( - f'
Skill body for {name}.
' - for name, desc in skills +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", ) - core.write_text(f"\n{sections}\n", encoding="utf-8") -def _make_skill(name: str, desc: str = "A skill.", body: str = "") -> Skill: - return Skill(name=name, description=desc, path=Path("/dev/null"), provider_key="core", body=body) +def _write_rule_md(tmp_path, name, body="Rule body."): + rules_dir = tmp_path / RULES_BASE_PATH + rules_dir.mkdir(parents=True, exist_ok=True) + (rules_dir / f"{name}.md").write_text(body, encoding="utf-8") -def _run_command(cmd_class, container, args: list[str] | None = None): - """Execute a command's handle() with lightweight fake IO.""" +def _run(cmd_class, container, args=None): cmd = cmd_class() cmd.set_container(container) - - option_values: dict = {} - if args: - for arg in args: - if arg.startswith("--"): - k = arg.lstrip("-").split("=")[0] - v = arg.split("=")[1] if "=" in arg else True - option_values[k] = v - - output_lines: list[str] = [] - cmd.option = lambda k, default=None: option_values.get(k, default) - cmd.line = lambda msg, *a, **kw: output_lines.append(msg) - cmd.info = lambda msg, *a, **kw: output_lines.append(msg) - - return_code = cmd.handle() - return return_code, output_lines + 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) + return cmd.handle(), lines -# =========================================================================== -# SkillsSyncCommand -# =========================================================================== - +# -- skills:sync -- class TestSkillsSyncCommand: def test_sync_all_writes_claude_and_gemini(self, tmp_path, app): - _write_core_html(tmp_path, [("orm-routing", "ORM routing skill")]) - - registry = SkillRegistry(app) - app.bind("skills.registry", registry) - - return_code, lines = _run_command(SkillsSyncCommand, app, args=["--target=all"]) - - assert return_code == 0 + _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_core_html(tmp_path, [("console-commands", "Artisan commands")]) + _write_skill_md(tmp_path, "console-commands", "Commands") app.bind("skills.registry", SkillRegistry(app)) - - return_code, _ = _run_command(SkillsSyncCommand, app, args=["--target=claude"]) - - assert return_code == 0 + _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_core_html(tmp_path, [("fastapi-routing", "FastAPI routing")]) + _write_skill_md(tmp_path, "fastapi-routing", "Routing") app.bind("skills.registry", SkillRegistry(app)) - - return_code, _ = _run_command(SkillsSyncCommand, app, args=["--target=gemini"]) - - assert return_code == 0 + _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 + assert any("Unknown target" in l for l in lines) - return_code, lines = _run_command(SkillsSyncCommand, app, args=["--target=codex"]) - - assert return_code == 1 - assert any("Unknown target" in ln for ln in lines) - - def test_sync_no_core_html_exits_gracefully(self, tmp_path, app): - # No core.html file → registry returns empty list + def test_sync_no_skills_exits_gracefully(self, tmp_path, app): app.bind("skills.registry", SkillRegistry(app)) + code, lines = _run(SkillsSyncCommand, app) + assert code == 0 + assert any("No skills" in l for l in lines) - return_code, lines = _run_command(SkillsSyncCommand, app) - - assert return_code == 0 - assert any("No skills" in ln for ln in lines) - - def test_sync_prune_flag_removes_old_claude_skills(self, tmp_path, app): - # Pre-create an orphan skill dir + 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("---\nname: old-skill\n---\n") - - _write_core_html(tmp_path, [("new-skill", "New skill")]) + (old_dir / "SKILL.md").write_text("old") + _write_skill_md(tmp_path, "new-skill", "New") app.bind("skills.registry", SkillRegistry(app)) - - return_code, lines = _run_command(SkillsSyncCommand, app, args=["--target=claude", "--prune"]) - - assert return_code == 0 + _run(SkillsSyncCommand, app, ["--target=claude", "--prune"]) assert not old_dir.exists() - assert any("Pruned" in ln for ln in lines) - - def test_sync_claude_skill_content_includes_body(self, tmp_path, app): - core = tmp_path / CORE_HTML_PATH - core.parent.mkdir(parents=True, exist_ok=True) - core.write_text( - '
' - "Use `Model.where(...)` for filtering." - "
", - encoding="utf-8", - ) - app.bind("skills.registry", SkillRegistry(app)) - - _run_command(SkillsSyncCommand, app, args=["--target=claude"]) - - content = (tmp_path / ".claude" / "skills" / "orm" / "SKILL.md").read_text() - assert "Model.where" in content - - def test_command_name_and_description(self): - cmd = SkillsSyncCommand() - assert cmd.name == "skills:sync" - assert "sync" in cmd.description.lower() + def test_command_name(self): + assert SkillsSyncCommand().name == "skills:sync" -# =========================================================================== -# SkillsListCommand -# =========================================================================== +# -- skills:list -- class TestSkillsListCommand: - def test_list_shows_skills_from_core_html(self, tmp_path, app): - _write_core_html( - tmp_path, - [("fastapi-routing", "FastAPI routing helpers"), ("orm-queries", "ORM query helpers")], - ) + 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) + assert code == 0 + out = "\n".join(lines) + assert "fastapi-routing" in out + assert "orm-queries" in out - return_code, lines = _run_command(SkillsListCommand, app) - - assert return_code == 0 - all_output = "\n".join(lines) - assert "fastapi-routing" in all_output - assert "orm-queries" in all_output - - def test_list_shows_pending_when_not_synced(self, tmp_path, app): - _write_core_html(tmp_path, [("my-skill", "My skill")]) + def test_list_no_skills_shows_message(self, tmp_path, app): app.bind("skills.registry", SkillRegistry(app)) + code, lines = _run(SkillsListCommand, app) + assert code == 0 + assert any("No skills" in l for l in lines) + + def test_command_name(self): + assert SkillsListCommand().name == "skills:list" + + +# -- rules:sync -- + +class TestRulesSyncCommand: + def test_sync_claude_creates_rule_files(self, tmp_path, app): + _write_rule_md(tmp_path, "http-client", "Always set timeout.") + app.bind("rules.registry", RulesRegistry(app)) + code, _ = _run(RulesSyncCommand, app, ["--target=claude"]) + assert code == 0 + assert (tmp_path / ".claude" / "rules" / "http-client.md").exists() + + def test_sync_gemini_updates_gemini_md(self, tmp_path, app): + _write_rule_md(tmp_path, "http-client", "Always set timeout.") + app.bind("rules.registry", RulesRegistry(app)) + code, _ = _run(RulesSyncCommand, app, ["--target=gemini"]) + assert code == 0 + content = (tmp_path / "GEMINI.md").read_text() + assert "" in content + assert "http-client" in content + + def test_sync_all_writes_both(self, tmp_path, app): + _write_rule_md(tmp_path, "orm-queries", "Use async ORM.") + app.bind("rules.registry", RulesRegistry(app)) + code, _ = _run(RulesSyncCommand, app, ["--target=all"]) + assert code == 0 + assert (tmp_path / ".claude" / "rules" / "orm-queries.md").exists() + assert (tmp_path / "GEMINI.md").exists() - return_code, lines = _run_command(SkillsListCommand, app) - all_output = "\n".join(lines) - - assert "pending" in all_output - - def test_list_shows_synced_when_claude_file_exists(self, tmp_path, app): - skill_dir = tmp_path / ".claude" / "skills" / "my-skill" - skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text("---\nname: my-skill\n---\n") - - _write_core_html(tmp_path, [("my-skill", "My skill")]) - app.bind("skills.registry", SkillRegistry(app)) - - return_code, lines = _run_command(SkillsListCommand, app) - all_output = "\n".join(lines) - - assert "synced" in all_output - - def test_list_no_core_html_shows_message(self, tmp_path, app): - app.bind("skills.registry", SkillRegistry(app)) - - return_code, lines = _run_command(SkillsListCommand, app) - - assert return_code == 0 - assert any("No skills" in ln for ln in lines) - - def test_command_name_and_description(self): - cmd = SkillsListCommand() - assert cmd.name == "skills:list" + def test_sync_prune_removes_stale_rules(self, tmp_path, app): + old_rule = tmp_path / ".claude" / "rules" / "old-rule.md" + old_rule.parent.mkdir(parents=True) + old_rule.write_text("old rule") + _write_rule_md(tmp_path, "new-rule", "New rule.") + app.bind("rules.registry", RulesRegistry(app)) + _run(RulesSyncCommand, app, ["--target=claude", "--prune"]) + assert not old_rule.exists() + + def test_no_rules_exits_gracefully(self, tmp_path, app): + app.bind("rules.registry", RulesRegistry(app)) + code, lines = _run(RulesSyncCommand, app) + assert code == 0 + assert any("No rules" in l for l in lines) + + def test_unknown_target_returns_error(self, tmp_path, app): + app.bind("rules.registry", RulesRegistry(app)) + code, lines = _run(RulesSyncCommand, app, ["--target=codex"]) + assert code == 1 + + def test_command_name(self): + assert RulesSyncCommand().name == "rules:sync" + + +# -- rules:list -- + +class TestRulesListCommand: + def test_list_shows_rules(self, tmp_path, app): + _write_rule_md(tmp_path, "http-client") + _write_rule_md(tmp_path, "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 + + def test_list_shows_synced_status(self, tmp_path, app): + _write_rule_md(tmp_path, "http-client") + (tmp_path / ".claude" / "rules").mkdir(parents=True) + (tmp_path / ".claude" / "rules" / "http-client.md").write_text("synced") + app.bind("rules.registry", RulesRegistry(app)) + _, lines = _run(RulesListCommand, app) + assert "synced" in "\n".join(lines) + + def test_no_rules_message(self, tmp_path, app): + app.bind("rules.registry", RulesRegistry(app)) + code, lines = _run(RulesListCommand, app) + assert any("No rules" in l for l in lines) + + def test_command_name(self): + assert RulesListCommand().name == "rules:list" diff --git a/fastapi_startkit/tests/skills/test_registry.py b/fastapi_startkit/tests/skills/test_registry.py index 62b9674d..b1da0dcc 100644 --- a/fastapi_startkit/tests/skills/test_registry.py +++ b/fastapi_startkit/tests/skills/test_registry.py @@ -1,4 +1,4 @@ -"""Tests for SkillRegistry reading from .ai/deployments/core.html (task #139).""" +"""Tests for SkillRegistry reading from .ai/fastapi-startkit/skill/*/SKILL.md.""" from __future__ import annotations @@ -12,17 +12,11 @@ from fastapi_startkit.skills.registry import ( Skill, SkillRegistry, - _CoreHtmlParser, - parse_core_html, - CORE_HTML_PATH, + SKILLS_BASE_PATH, + _parse_frontmatter, ) -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - @pytest.fixture(autouse=True) def restore_container(): original = Container._instance @@ -35,186 +29,102 @@ def app(tmp_path): return Application(base_path=tmp_path, env="testing") -def _write_core_html(tmp_path: Path, content: str) -> Path: - """Write *content* to the canonical core.html location under *tmp_path*.""" - core = tmp_path / CORE_HTML_PATH - core.parent.mkdir(parents=True, exist_ok=True) - core.write_text(content, encoding="utf-8") - return core - - -# --------------------------------------------------------------------------- -# _CoreHtmlParser unit tests -# --------------------------------------------------------------------------- - - -def test_parser_extracts_single_section(): - html = textwrap.dedent("""\ -
- Use router.get() to register routes. -
- """) - parser = _CoreHtmlParser() - parser.feed(html) - assert len(parser.skills) == 1 - s = parser.skills[0] - assert s["name"] == "fastapi-routing" - assert s["description"] == "FastAPI routing helpers." - assert "router.get()" in s["body"] - - -def test_parser_extracts_multiple_sections(): - html = textwrap.dedent("""\ -
Body A.
-
Body B.
- """) - parser = _CoreHtmlParser() - parser.feed(html) - assert len(parser.skills) == 2 - assert parser.skills[0]["name"] == "skill-a" - assert parser.skills[1]["name"] == "skill-b" - - -def test_parser_skips_sections_without_data_skill(): - html = '
body
' - parser = _CoreHtmlParser() - parser.feed(html) - assert parser.skills == [] - - -def test_parser_ignores_non_section_tags(): - html = textwrap.dedent("""\ -
some wrapper
-
content
-

irrelevant

- """) - parser = _CoreHtmlParser() - parser.feed(html) - assert len(parser.skills) == 1 - assert parser.skills[0]["name"] == "valid" - - -def test_parser_handles_html_comments_inside_section(): - html = textwrap.dedent("""\ -
- - Some body text. -
- """) - parser = _CoreHtmlParser() - parser.feed(html) - assert len(parser.skills) == 1 - body = parser.skills[0]["body"] - assert "Some body text." in body - - -def test_parser_trims_body_whitespace(): - html = '
\n\n trimmed \n\n
' - parser = _CoreHtmlParser() - parser.feed(html) - assert parser.skills[0]["body"] == "trimmed" - - -# --------------------------------------------------------------------------- -# parse_core_html -# --------------------------------------------------------------------------- - - -def test_parse_core_html_returns_skill_dicts(tmp_path): - core = _write_core_html(tmp_path, textwrap.dedent("""\ -
- Use Model.where().get() for filtering. -
- """)) - skills = parse_core_html(core) - assert len(skills) == 1 - assert skills[0]["name"] == "orm-queries" - - -# --------------------------------------------------------------------------- -# SkillRegistry -# --------------------------------------------------------------------------- - - -def test_registry_discovers_skills_from_core_html(tmp_path, app): - _write_core_html(tmp_path, textwrap.dedent("""\ -
- Router body. -
-
- ORM body. -
- """)) - - registry = SkillRegistry(app) - skills = registry.discover() +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 + + +# -- _parse_frontmatter -- + +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_parse_frontmatter_missing_fence(): + text = "No front-matter here." + meta, body = _parse_frontmatter(text) + assert meta == {} + assert body == text + +def test_parse_frontmatter_unclosed_fence(): + meta, body = _parse_frontmatter("---\nname: oops\n") + assert meta == {} + + +# -- SkillRegistry -- + +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().") + + skills = SkillRegistry(app).discover() assert len(skills) == 2 - names = {s.name for s in skills} - assert names == {"fastapi-routing", "orm-queries"} + assert {s.name for s in skills} == {"fastapi-routing", "orm-queries"} -def test_registry_returns_empty_list_when_core_html_missing(tmp_path, app): - registry = SkillRegistry(app) - skills = registry.discover() - assert skills == [] +def test_registry_returns_empty_when_skills_dir_missing(tmp_path, app): + assert SkillRegistry(app).discover() == [] -def test_registry_skill_path_points_to_core_html(tmp_path, app): - _write_core_html(tmp_path, '
body
') - registry = SkillRegistry(app) - skills = registry.discover() - assert skills[0].path == tmp_path / CORE_HTML_PATH +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 -def test_registry_skill_body_is_captured(tmp_path, app): - _write_core_html(tmp_path, textwrap.dedent("""\ -
- Use `Model.where(...)` -
- """)) - registry = SkillRegistry(app) - skill = registry.get("orm") +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_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_registry_caches_results(tmp_path, app): - _write_core_html(tmp_path, '
body
') - registry = SkillRegistry(app) - first = registry.discover() - second = registry.discover() - assert first is second + _write_skill(tmp_path, "s", "d") + r = SkillRegistry(app) + assert r.discover() is r.discover() def test_registry_reset_clears_cache(tmp_path, app): - _write_core_html(tmp_path, '
body
') - registry = SkillRegistry(app) - registry.discover() - assert registry._skills is not None - registry.reset() - assert registry._skills is None + _write_skill(tmp_path, "s", "d") + r = SkillRegistry(app) + r.discover() + r.reset() + assert r._skills is None def test_registry_get_returns_none_for_unknown(tmp_path, app): - _write_core_html(tmp_path, '
body
') - registry = SkillRegistry(app) - assert registry.get("does-not-exist") is None + _write_skill(tmp_path, "s", "d") + assert SkillRegistry(app).get("does-not-exist") is None def test_registry_get_returns_skill_by_name(tmp_path, app): - _write_core_html(tmp_path, textwrap.dedent("""\ -
- Extend Command class. -
- """)) - registry = SkillRegistry(app) - skill = registry.get("console-commands") + _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 == "core" + assert skill.provider_key == "fastapi-startkit" -def test_registry_core_html_path_property(tmp_path, app): - registry = SkillRegistry(app) - assert registry.core_html_path == tmp_path / ".ai" / "deployments" / "core.html" +def test_registry_skills_base_path_property(tmp_path, app): + r = SkillRegistry(app) + assert r.skills_base_path == tmp_path / ".ai" / "fastapi-startkit" / "skill" From aa3eae717a03ca62db51f48e781af6342dabf74b Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 16:06:02 -0700 Subject: [PATCH 04/11] fix(skills): remove unused adapter imports in sync commands (ruff F401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicate `ClaudeAdapter`/`GeminiAdapter` and `ClaudeRulesAdapter`/`GeminiRulesAdapter` imports from `handle()` in both sync commands — these are only needed in `_resolve_adapters()`. Note: `_skills_dir_for()` referenced in the reviewer comment no longer exists — it was removed as part of the redesign from per-provider skills/ directories to the centralised .ai/fastapi-startkit/skill/*/SKILL.md layout. The SkillRegistry._load() static method now handles file discovery directly. ruff check src/ → All checks passed. pytest tests/skills/ → 48 passed. Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/skills/commands/sync.py | 6 ++++-- .../src/fastapi_startkit/skills/rules/commands/sync.py | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py index 93519140..4745cd0a 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py @@ -38,7 +38,6 @@ class SkillsSyncCommand(Command): def handle(self) -> int: from fastapi_startkit.skills.registry import SkillRegistry - from fastapi_startkit.skills.adapters import ClaudeAdapter, GeminiAdapter registry: SkillRegistry = self.container.make("skills.registry") skills = registry.discover() @@ -54,7 +53,10 @@ def handle(self) -> int: return 1 if not skills: - self.line("No skills found in any registered provider.") + 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}…") diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py index 214a4ceb..2d3edc03 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py @@ -38,7 +38,6 @@ class RulesSyncCommand(Command): def handle(self) -> int: from fastapi_startkit.skills.rules.registry import RulesRegistry - from fastapi_startkit.skills.rules.adapters import ClaudeRulesAdapter, GeminiRulesAdapter registry: RulesRegistry = self.container.make("rules.registry") rules = registry.discover() From c11b214a07b07530d98ca6f8269071fd469ee787 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 16:25:18 -0700 Subject: [PATCH 05/11] refactor(skills): nest rules inside parent skill directory (Laravel Boost convention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules now live co-located with the SKILL.md they document, matching the Laravel Boost layout exactly: .ai/fastapi-startkit/skill// SKILL.md rules/ .md Changes: - RulesRegistry scans `.ai/fastapi-startkit/skill/*/rules/*.md` (was `rules/*.md`) - Rule dataclass gains `skill_name` field (owning skill dir name) - RulesRegistry.for_skill(name) helper for scoped lookups - ClaudeRulesAdapter output: .claude/skills//rules/.md (mirrors source nesting; pending final confirmation from reviewer 361) - GeminiRulesAdapter groups rules under their skill heading in the block - Prune lifecycle: ClaudeAdapter.prune() removes the whole skill dir (shutil.rmtree) which automatically takes its nested rules/ with it; ClaudeRulesAdapter.prune() handles stale individual rule files within still-present skills - Stubs: stubs/rules/http-client.md moved to stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md - SkillsServiceProvider.publishes() updated to new nested paths - tests/skills/test_commands.py updated throughout; added test_prune_removes_skill_dir_removes_its_rules_too ruff check src/ → All checks passed. pytest tests/skills/ → 49 passed, 0 failures. Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/skills/__init__.py | 10 +- .../src/fastapi_startkit/skills/provider.py | 9 +- .../fastapi_startkit/skills/rules/__init__.py | 9 +- .../skills/rules/adapters/claude.py | 70 ++++++++++---- .../skills/rules/adapters/gemini.py | 24 +++-- .../skills/rules/commands/list.py | 22 +++-- .../fastapi_startkit/skills/rules/registry.py | 96 +++++++++++-------- .../rules/http-client.md | 0 .../tests/skills/test_commands.py | 90 +++++++++++------ 9 files changed, 217 insertions(+), 113 deletions(-) rename fastapi_startkit/src/fastapi_startkit/skills/stubs/{ => .ai/fastapi-startkit/skill/fastapi-best-practices}/rules/http-client.md (100%) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py index 09aee77b..4d66267b 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py @@ -1,14 +1,17 @@ """fastapi_startkit.skills — AI skill & rules registry and adapters. -Skills -> .ai/fastapi-startkit/skill/{name}/SKILL.md -Rules -> rules/{name}.md +Skills and their nested rules both live under: + .ai/fastapi-startkit/skill// + 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, RULES_BASE_PATH +from .rules import Rule, RulesRegistry __all__ = [ "Skill", @@ -17,6 +20,5 @@ "SKILLS_BASE_PATH", "Rule", "RulesRegistry", - "RULES_BASE_PATH", "_parse_frontmatter", ] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/provider.py b/fastapi_startkit/src/fastapi_startkit/skills/provider.py index e252225e..5b449c29 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/provider.py @@ -18,7 +18,7 @@ class SkillsServiceProvider(Provider): This copies: - .ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md - - rules/http-client.md + - .ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md """ provider_key = "skills" @@ -41,12 +41,13 @@ def boot(self) -> None: RulesListCommand, ]) + _skill_stub = _STUBS_DIR / ".ai" / "fastapi-startkit" / "skill" / "fastapi-best-practices" self.publishes( { - str(_STUBS_DIR / ".ai" / "fastapi-startkit" / "skill" / "fastapi-best-practices" / "SKILL.md"): + str(_skill_stub / "SKILL.md"): ".ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md", - str(_STUBS_DIR / "rules" / "http-client.md"): - "rules/http-client.md", + str(_skill_stub / "rules" / "http-client.md"): + ".ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md", }, tag="skills", ) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py index 79181272..ec91e0b8 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py @@ -1,5 +1,8 @@ -"""Rules sub-system — per-topic coding rule files at ``rules/*.md``.""" +"""Rules sub-system — per-topic rule files nested inside each skill directory. -from .registry import Rule, RulesRegistry, RULES_BASE_PATH +Layout: .ai/fastapi-startkit/skill//rules/.md +""" -__all__ = ["Rule", "RulesRegistry", "RULES_BASE_PATH"] +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/claude.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py index 21b95863..39d06061 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py @@ -1,4 +1,17 @@ -"""ClaudeRulesAdapter — deploys rules to ``.claude/rules/.md``.""" +"""ClaudeRulesAdapter — deploys rules nested inside their parent skill directory. + +Output layout mirrors the source convention:: + + .claude/skills//rules/.md + +This means rules are co-located with the skill they document, so removing a +skill via ``ClaudeAdapter.prune()`` automatically removes its rules too +(``shutil.rmtree`` takes the whole skill directory). + +NOTE: The exact output path convention is pending final confirmation from the +PR reviewer (agent 361). The current implementation uses option (a): +``.claude/skills//rules/.md`` — mirrors source nesting. +""" from __future__ import annotations @@ -10,10 +23,12 @@ class ClaudeRulesAdapter(BaseAdapter): - """Writes each rule to ``.claude/rules/.md``. + """Writes each rule to ``.claude/skills//rules/.md``. - Writes are idempotent — the file is only (re)written when its content - would change. ``--prune`` removes rule files no longer in the registry. + Writes are idempotent — the file is only (re)written when content changes. + ``prune()`` removes stale rule files from skills that still exist; rules + belonging to *removed* skills are cleaned up automatically when + ``ClaudeAdapter.prune()`` removes the parent skill directory. """ name = "claude-rules" @@ -21,32 +36,55 @@ class ClaudeRulesAdapter(BaseAdapter): def render(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] messages: list[str] = [] for rule in rules: - dest = self._rule_path(rule.name) + 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.name}.md") + messages.append( + f"[claude] {verb} .claude/skills/{rule.skill_name}/rules/{rule.name}.md" + ) return messages def prune(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] - """Remove ``.claude/rules/.md`` files not in *rules*.""" + """Remove stale rule files from skills that still exist in *rules*. + + Rules whose entire parent skill directory has been removed by + ``ClaudeAdapter.prune()`` are already gone — this method only needs + to handle individual rule files that were removed from a still-present + skill. + """ messages: list[str] = [] - known = {r.name for r in rules} - rules_dir = self.base_path / ".claude" / "rules" - if not rules_dir.is_dir(): + # Build a set of (skill_name, rule_name) that should exist + live = {(r.skill_name, r.name) for r in rules} + + skills_root = self.base_path / ".claude" / "skills" + if not skills_root.is_dir(): return messages - for child in sorted(rules_dir.glob("*.md")): - if child.stem not in known: - child.unlink() - messages.append(f"[claude] Pruned .claude/rules/{child.name}") + for skill_dir in sorted(skills_root.iterdir()): + if not skill_dir.is_dir(): + continue + rules_dir = skill_dir / "rules" + if not rules_dir.is_dir(): + continue + for rule_file in sorted(rules_dir.glob("*.md")): + key = (skill_dir.name, rule_file.stem) + if key not in live: + rule_file.unlink() + messages.append( + f"[claude] Pruned .claude/skills/{skill_dir.name}/rules/{rule_file.name}" + ) + # Remove empty rules/ dir + if rules_dir.is_dir() and not any(rules_dir.iterdir()): + rules_dir.rmdir() + return messages # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ - def _rule_path(self, rule_name: str) -> Path: - return self.base_path / ".claude" / "rules" / f"{rule_name}.md" + def _rule_path(self, skill_name: str, rule_name: str) -> Path: + return self.base_path / ".claude" / "skills" / skill_name / "rules" / f"{rule_name}.md" @staticmethod def _write_idempotent(path: Path, content: str) -> bool: diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py index ecf34702..6aa5e8a8 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py @@ -1,4 +1,4 @@ -"""GeminiRulesAdapter — splices rules into ``GEMINI.md`` via marker block.""" +"""GeminiRulesAdapter — splices rules into ``GEMINI.md``, grouped by skill.""" from __future__ import annotations @@ -15,6 +15,7 @@ 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. """ @@ -28,7 +29,7 @@ def render(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] 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.""" + """Re-render with the current (shorter) rule list — equivalent to pruning.""" return self.render(rules) # ------------------------------------------------------------------ @@ -37,13 +38,20 @@ def prune(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] @staticmethod def _build_section(rules: Sequence[Rule]) -> str: - parts = [_MARKER_START] + # Group rules by skill_name preserving order + by_skill: dict[str, list[Rule]] = {} for rule in 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") + 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) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py index 3c45b6c9..e9155646 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py @@ -1,4 +1,4 @@ -"""rules:list — list all rules found in rules/ and their sync status.""" +"""rules:list — list all rules nested inside skill directories.""" from __future__ import annotations @@ -8,7 +8,7 @@ class RulesListCommand(Command): - """List all rule files in ``rules/`` and their deployment status. + """List all rule files and their deployment status. Example usage:: @@ -16,7 +16,7 @@ class RulesListCommand(Command): """ name = "rules:list" - description = "List rules/*.md files and their AI agent sync status." + description = "List skill-nested rules and their AI agent sync status." def handle(self) -> int: from fastapi_startkit.skills.rules.registry import RulesRegistry @@ -25,24 +25,28 @@ def handle(self) -> int: rules = registry.discover() if not rules: - self.line("No rules found in rules/. Create rules/*.md files first.") + 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) in rules/:") + self.line(f" Found {len(rules)} rule(s):") self.line("") - header = f" {'NAME':<30} {'CLAUDE':<10} {'GEMINI':<10} PATH" + header = f" {'SKILL':<28} {'RULE':<25} {'CLAUDE':<10} {'GEMINI':<10}" self.line(header) self.line(" " + "-" * (len(header) - 2)) for rule in rules: - claude_status = "synced" if (base_path / ".claude" / "rules" / f"{rule.name}.md").exists() else "pending" + claude_dest = ( + base_path / ".claude" / "skills" / rule.skill_name / "rules" / f"{rule.name}.md" + ) + claude_status = "synced" if claude_dest.exists() else "pending" gemini_status = self._gemini_status(base_path) - rel = rule.path.relative_to(base_path) if rule.path.is_absolute() else rule.path - self.line(f" {rule.name:<30} {claude_status:<10} {gemini_status:<10} {rel}") + self.line( + f" {rule.skill_name:<28} {rule.name:<25} {claude_status:<10} {gemini_status:<10}" + ) self.line("") return 0 diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py index 3632acda..7abb94e5 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py @@ -1,20 +1,23 @@ -"""RulesRegistry — discovers per-topic rule files from ``rules/``. - -Rules are individual Markdown files that contain detailed coding guidelines:: - - rules/ - http-client.md ← HTTP client best practices - orm-queries.md ← ORM / database query rules - validation.md ← Request validation rules - error-handling.md ← Error handling patterns - -Each file is plain Markdown. The rule **name** is derived from the file stem -(``http-client.md`` → ``http-client``). An optional YAML front-matter block -may supply an explicit name or description, but it is not required. - -Rules are referenced from skill files (e.g. the Quick Reference section of -``.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md``) and are also -deployed individually to every configured AI agent target. +"""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/skill/ + fastapi-best-practices/ + 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 @@ -26,22 +29,23 @@ if TYPE_CHECKING: from fastapi_startkit.application import Application -#: Location of rule files relative to the project root. -RULES_BASE_PATH = Path("rules") +#: Root under which all skill (and nested rule) directories live. +SKILLS_BASE_PATH = Path(".ai") / "fastapi-startkit" / "skill" @dataclass class Rule: - """A single per-topic coding rule loaded from ``rules/.md``.""" + """A per-topic coding rule nested inside a skill directory.""" - name: str # derived from file stem, e.g. "http-client" + 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) # full markdown content - description: str = "" # from frontmatter if present + body: str = field(default="", repr=False) + description: str = "" def _parse_frontmatter(text: str) -> tuple[dict, str]: - """Parse optional YAML front-matter and return ``(meta, body)``.""" + """Parse optional YAML front-matter; return ``(meta, body)``.""" lines = text.splitlines(keepends=True) if not lines or lines[0].strip() != "---": return {}, text @@ -72,13 +76,16 @@ def _parse_frontmatter(text: str) -> tuple[dict, str]: class RulesRegistry: - """Loads :class:`Rule` objects from ``{base_path}/rules/*.md``. + """Loads :class:`Rule` objects from nested ``rules/`` dirs inside each skill. + + Scan path: ``{base_path}/.ai/fastapi-startkit/skill/*/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: @@ -90,31 +97,42 @@ def __init__(self, app: "Application") -> None: # ------------------------------------------------------------------ @property - def rules_base_path(self) -> Path: - """Absolute path to the ``rules/`` directory.""" - return Path(self._app.base_path) / RULES_BASE_PATH + 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 in ``rules/``.""" + """Return (and cache) all rules found across all skill directories.""" if self._rules is not None: return self._rules - base = self.rules_base_path + base = self.skills_base_path if not base.is_dir(): self._rules = [] return self._rules self._rules = [] - for md_file in sorted(base.glob("*.md")): - rule = self._load(md_file) - if rule is not None: - self._rules.append(rule) + 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 get(self, name: str) -> Rule | None: - """Return the :class:`Rule` with *name* (file stem), or *None*.""" - return next((r for r in self.discover() if r.name == name), None) + 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.""" @@ -125,20 +143,20 @@ def reset(self) -> None: # ------------------------------------------------------------------ @staticmethod - def _load(md_file: Path) -> Rule | None: + 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/rules/http-client.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/skills/stubs/rules/http-client.md rename to fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py index 30d382a0..a581a8fb 100644 --- a/fastapi_startkit/tests/skills/test_commands.py +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -3,14 +3,13 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import MagicMock import pytest from fastapi_startkit.application import Application from fastapi_startkit.container.container import Container -from fastapi_startkit.skills.registry import Skill, SkillRegistry, SKILLS_BASE_PATH -from fastapi_startkit.skills.rules.registry import Rule, RulesRegistry, RULES_BASE_PATH +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 @@ -38,10 +37,11 @@ def _write_skill_md(tmp_path, name, description): ) -def _write_rule_md(tmp_path, name, body="Rule body."): - rules_dir = tmp_path / RULES_BASE_PATH +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"{name}.md").write_text(body, encoding="utf-8") + (rules_dir / f"{rule_name}.md").write_text(body, encoding="utf-8") def _run(cmd_class, container, args=None): @@ -60,7 +60,9 @@ def _run(cmd_class, container, args=None): return cmd.handle(), lines -# -- skills:sync -- +# =========================================================================== +# skills:sync +# =========================================================================== class TestSkillsSyncCommand: def test_sync_all_writes_claude_and_gemini(self, tmp_path, app): @@ -89,7 +91,6 @@ 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 - assert any("Unknown target" in l for l in lines) def test_sync_no_skills_exits_gracefully(self, tmp_path, app): app.bind("skills.registry", SkillRegistry(app)) @@ -110,7 +111,9 @@ def test_command_name(self): assert SkillsSyncCommand().name == "skills:sync" -# -- skills:list -- +# =========================================================================== +# skills:list +# =========================================================================== class TestSkillsListCommand: def test_list_shows_skills(self, tmp_path, app): @@ -133,41 +136,65 @@ def test_command_name(self): assert SkillsListCommand().name == "skills:list" -# -- rules:sync -- +# =========================================================================== +# rules:sync (rules nested inside skills) +# =========================================================================== class TestRulesSyncCommand: - def test_sync_claude_creates_rule_files(self, tmp_path, app): - _write_rule_md(tmp_path, "http-client", "Always set timeout.") + 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 - assert (tmp_path / ".claude" / "rules" / "http-client.md").exists() + dest = tmp_path / ".claude" / "skills" / "fastapi-best-practices" / "rules" / "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, "http-client", "Always set timeout.") + _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"]) 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-queries", "Use async ORM.") + _write_rule_md(tmp_path, "orm-best-practices", "queries", "Use async ORM.") app.bind("rules.registry", RulesRegistry(app)) code, _ = _run(RulesSyncCommand, app, ["--target=all"]) assert code == 0 - assert (tmp_path / ".claude" / "rules" / "orm-queries.md").exists() + assert (tmp_path / ".claude" / "skills" / "orm-best-practices" / "rules" / "queries.md").exists() assert (tmp_path / "GEMINI.md").exists() - def test_sync_prune_removes_stale_rules(self, tmp_path, app): - old_rule = tmp_path / ".claude" / "rules" / "old-rule.md" - old_rule.parent.mkdir(parents=True) - old_rule.write_text("old rule") - _write_rule_md(tmp_path, "new-rule", "New rule.") + 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" / "skills" / "fastapi-best-practices" / "rules" / "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 old_rule.exists() + assert not stale.exists() + + def test_prune_removes_skill_dir_removes_its_rules_too(self, tmp_path, app): + """When ClaudeAdapter prunes a skill, its nested rules/ are gone too.""" + from fastapi_startkit.skills.adapters.claude import ClaudeAdapter + from fastapi_startkit.skills.registry import Skill + # Simulate a previously synced skill with a nested rule + old_skill = tmp_path / ".claude" / "skills" / "dead-skill" + old_rule = old_skill / "rules" / "some-rule.md" + old_rule.parent.mkdir(parents=True) + old_rule.write_text("rule content") + (old_skill / "SKILL.md").write_text("---\nname: dead-skill\n---\n") + + # Prune with an empty skills list removes the whole dir (including rules/) + adapter = ClaudeAdapter(base_path=tmp_path) + messages = adapter.prune([]) + assert not old_skill.exists() + assert any("Pruned" in m for m in messages) def test_no_rules_exits_gracefully(self, tmp_path, app): app.bind("rules.registry", RulesRegistry(app)) @@ -177,19 +204,21 @@ def test_no_rules_exits_gracefully(self, tmp_path, app): def test_unknown_target_returns_error(self, tmp_path, app): app.bind("rules.registry", RulesRegistry(app)) - code, lines = _run(RulesSyncCommand, app, ["--target=codex"]) + code, _ = _run(RulesSyncCommand, app, ["--target=codex"]) assert code == 1 def test_command_name(self): assert RulesSyncCommand().name == "rules:sync" -# -- rules:list -- +# =========================================================================== +# rules:list +# =========================================================================== class TestRulesListCommand: def test_list_shows_rules(self, tmp_path, app): - _write_rule_md(tmp_path, "http-client") - _write_rule_md(tmp_path, "validation") + _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 @@ -198,16 +227,17 @@ def test_list_shows_rules(self, tmp_path, app): assert "validation" in out def test_list_shows_synced_status(self, tmp_path, app): - _write_rule_md(tmp_path, "http-client") - (tmp_path / ".claude" / "rules").mkdir(parents=True) - (tmp_path / ".claude" / "rules" / "http-client.md").write_text("synced") + _write_rule_md(tmp_path, "fastapi-best-practices", "http-client") + dest = tmp_path / ".claude" / "skills" / "fastapi-best-practices" / "rules" / "http-client.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) def test_no_rules_message(self, tmp_path, app): app.bind("rules.registry", RulesRegistry(app)) - code, lines = _run(RulesListCommand, app) + _, lines = _run(RulesListCommand, app) assert any("No rules" in l for l in lines) def test_command_name(self): From 073065124188e35019fba99e8272d9f02bc263f6 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 17:54:24 -0700 Subject: [PATCH 06/11] fix(skills): remove unused imports in test files to pass ruff F401 Drop `import pytest` from test_adapters.py, `from pathlib import Path` and the inline `Skill` import from test_commands.py, and `import textwrap` plus the unused `Skill` from test_registry.py's multi-import. Co-Authored-By: Claude Sonnet 4.6 --- fastapi_startkit/tests/skills/test_adapters.py | 2 -- fastapi_startkit/tests/skills/test_commands.py | 3 --- fastapi_startkit/tests/skills/test_registry.py | 2 -- 3 files changed, 7 deletions(-) diff --git a/fastapi_startkit/tests/skills/test_adapters.py b/fastapi_startkit/tests/skills/test_adapters.py index 62606cdf..adccd151 100644 --- a/fastapi_startkit/tests/skills/test_adapters.py +++ b/fastapi_startkit/tests/skills/test_adapters.py @@ -4,8 +4,6 @@ from pathlib import Path -import pytest - from fastapi_startkit.skills.registry import Skill from fastapi_startkit.skills.adapters.claude import ClaudeAdapter from fastapi_startkit.skills.adapters.gemini import GeminiAdapter, _MARKER_START, _MARKER_END diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py index a581a8fb..50a0f07f 100644 --- a/fastapi_startkit/tests/skills/test_commands.py +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -2,8 +2,6 @@ from __future__ import annotations -from pathlib import Path - import pytest from fastapi_startkit.application import Application @@ -182,7 +180,6 @@ def test_sync_prune_removes_stale_rule_within_skill(self, tmp_path, app): def test_prune_removes_skill_dir_removes_its_rules_too(self, tmp_path, app): """When ClaudeAdapter prunes a skill, its nested rules/ are gone too.""" from fastapi_startkit.skills.adapters.claude import ClaudeAdapter - from fastapi_startkit.skills.registry import Skill # Simulate a previously synced skill with a nested rule old_skill = tmp_path / ".claude" / "skills" / "dead-skill" old_rule = old_skill / "rules" / "some-rule.md" diff --git a/fastapi_startkit/tests/skills/test_registry.py b/fastapi_startkit/tests/skills/test_registry.py index b1da0dcc..ccf24f44 100644 --- a/fastapi_startkit/tests/skills/test_registry.py +++ b/fastapi_startkit/tests/skills/test_registry.py @@ -2,7 +2,6 @@ from __future__ import annotations -import textwrap from pathlib import Path import pytest @@ -10,7 +9,6 @@ from fastapi_startkit.application import Application from fastapi_startkit.container.container import Container from fastapi_startkit.skills.registry import ( - Skill, SkillRegistry, SKILLS_BASE_PATH, _parse_frontmatter, From dfe5843a0432f194beb7a2f027396a326b686a6c Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 18:17:38 -0700 Subject: [PATCH 07/11] fix(skills): route rules to .claude/rules//.md + lean stubs ClaudeRulesAdapter: output path corrected to .claude/rules//.md so rules are actually read by Claude Code (the old .claude/skills//rules/ path was silently ignored). prune() now independently scans .claude/rules/ instead of .claude/skills/. RulesListCommand: synced-status check updated to .claude/rules/ path. Tests: all path assertions updated; test_prune_removes_skill_dir_removes_its_rules_too replaced with test_prune_removes_stale_rule_skill_subdir which asserts that ClaudeRulesAdapter.prune() correctly targets .claude/rules/. Stubs: SKILL.md and http-client.md rewritten as terse actionable checklists (no filler commentary, minimal code examples only where they disambiguate). Co-Authored-By: Claude Sonnet 4.6 --- .../skills/rules/adapters/claude.py | 63 +++++---- .../skills/rules/commands/list.py | 2 +- .../skill/fastapi-best-practices/SKILL.md | 85 ++++-------- .../rules/http-client.md | 131 ++---------------- .../tests/skills/test_commands.py | 32 ++--- 5 files changed, 82 insertions(+), 231 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py index 39d06061..75917c88 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py @@ -1,16 +1,20 @@ -"""ClaudeRulesAdapter — deploys rules nested inside their parent skill directory. +"""ClaudeRulesAdapter — deploys rules to ``.claude/rules//.md``. -Output layout mirrors the source convention:: +Claude Code reads rules **only** from ``.claude/rules/``. Rules written +anywhere else (e.g. ``.claude/skills//rules/``) are silently ignored. - .claude/skills//rules/.md +Output layout:: -This means rules are co-located with the skill they document, so removing a -skill via ``ClaudeAdapter.prune()`` automatically removes its rules too -(``shutil.rmtree`` takes the whole skill directory). + .claude/rules/ + fastapi-best-practices/ + http-client.md + validation.md + orm-best-practices/ + queries.md -NOTE: The exact output path convention is pending final confirmation from the -PR reviewer (agent 361). The current implementation uses option (a): -``.claude/skills//rules/.md`` — mirrors source nesting. +``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 @@ -23,12 +27,12 @@ class ClaudeRulesAdapter(BaseAdapter): - """Writes each rule to ``.claude/skills//rules/.md``. + """Writes each rule to ``.claude/rules//.md``. Writes are idempotent — the file is only (re)written when content changes. - ``prune()`` removes stale rule files from skills that still exist; rules - belonging to *removed* skills are cleaned up automatically when - ``ClaudeAdapter.prune()`` removes the parent skill directory. + ``prune()`` scans ``.claude/rules/`` and removes stale rule files and + empty skill sub-directories. It operates independently of + ``ClaudeAdapter.prune()``. """ name = "claude-rules" @@ -40,42 +44,37 @@ def render(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] written = self._write_idempotent(dest, rule.body) verb = "Synced" if written else "Unchanged" messages.append( - f"[claude] {verb} .claude/skills/{rule.skill_name}/rules/{rule.name}.md" + 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 skills that still exist in *rules*. + """Remove stale rule files from ``.claude/rules/``. - Rules whose entire parent skill directory has been removed by - ``ClaudeAdapter.prune()`` are already gone — this method only needs - to handle individual rule files that were removed from a still-present - skill. + 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] = [] - # Build a set of (skill_name, rule_name) that should exist live = {(r.skill_name, r.name) for r in rules} - skills_root = self.base_path / ".claude" / "skills" - if not skills_root.is_dir(): + rules_root = self.base_path / ".claude" / "rules" + if not rules_root.is_dir(): return messages - for skill_dir in sorted(skills_root.iterdir()): + for skill_dir in sorted(rules_root.iterdir()): if not skill_dir.is_dir(): continue - rules_dir = skill_dir / "rules" - if not rules_dir.is_dir(): - continue - for rule_file in sorted(rules_dir.glob("*.md")): + 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/skills/{skill_dir.name}/rules/{rule_file.name}" + f"[claude] Pruned .claude/rules/{skill_dir.name}/{rule_file.name}" ) - # Remove empty rules/ dir - if rules_dir.is_dir() and not any(rules_dir.iterdir()): - rules_dir.rmdir() + # Remove empty skill sub-directory + if skill_dir.is_dir() and not any(skill_dir.iterdir()): + skill_dir.rmdir() return messages @@ -84,7 +83,7 @@ def prune(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] # ------------------------------------------------------------------ def _rule_path(self, skill_name: str, rule_name: str) -> Path: - return self.base_path / ".claude" / "skills" / skill_name / "rules" / f"{rule_name}.md" + return self.base_path / ".claude" / "rules" / skill_name / f"{rule_name}.md" @staticmethod def _write_idempotent(path: Path, content: str) -> bool: diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py index e9155646..1e97be96 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py @@ -40,7 +40,7 @@ def handle(self) -> int: for rule in rules: claude_dest = ( - base_path / ".claude" / "skills" / rule.skill_name / "rules" / f"{rule.name}.md" + 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) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md index 32c0fca7..10037216 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md @@ -1,75 +1,40 @@ --- name: fastapi-best-practices -description: "Apply this skill whenever writing, reviewing, or refactoring FastAPI Startkit code. Covers controllers, ORM models, migrations, providers, console commands, HTTP clients, validation, error handling, and architectural decisions. Use for code reviews and refactoring existing code to follow framework best practices." -license: MIT -metadata: - author: fastapi-startkit +description: Apply when writing or reviewing FastAPI Startkit code — routing, ORM, providers, commands, validation, and testing. --- -# FastAPI Startkit Best Practices +## Routing -Best practices for FastAPI Startkit, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with the framework docs. +- Use `Router` from `fastapi_startkit.fastapi`, not FastAPI's `APIRouter` directly. +- Use `router.resource(name, controller)` for CRUD; scope with `only=` or `excepts=`. +- Group routes by access level in separate `Router` instances (`guest`, `auth`). -## Consistency First +## ORM -Before applying any rule, check what the application already does. FastAPI Startkit offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. +- All DB operations are `async`/`await` — never call ORM methods synchronously. +- Eager-load relationships to prevent N+1 queries. +- Use `exists()` to check presence without fetching rows. +- `chunk()` for large result sets. -Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. These rules are defaults for when no pattern exists yet, not overrides. +## Providers -## Quick Reference +- Bind services in `register()`. Resolve dependencies only in `boot()`. +- Never call `app().make()` in business logic — inject via the container. -### 1. HTTP Client → `rules/http-client.md` +## Console commands -- Explicit `timeout` and `connect_timeout` on every request -- `retry()` with exponential backoff for external APIs -- Check response status or use `raise_for_status()` -- Use `asyncio.gather()` for concurrent independent requests -- Mock HTTP clients with `httpx` fakes and `respx` in tests +- Extend `Command` from `fastapi_startkit.console`. +- Register in provider `boot()` via `self.commands([...])`. +- Keep `handle()` thin — delegate to services. -### 2. ORM Queries → `rules/orm-queries.md` +## Validation & errors -- All DB operations are `async` / `await` -- Eager load relationships to prevent N+1 queries -- Use `exists()` to check without fetching rows -- `chunk()` for large datasets to avoid memory issues -- Index columns used in `WHERE`, `ORDER BY` +- Pydantic models for all request bodies; declare `response_model` on every endpoint. +- Register custom exception handlers in a provider, not inline in routes. +- Never expose stack traces to clients. -### 3. Validation → `rules/validation.md` +## Testing -- Use Pydantic models for all request bodies -- Declare `response_model` on every endpoint -- Never trust raw user input — always validate shapes and types -- Return structured error responses with RFC 7807 format - -### 4. Error Handling → `rules/error-handling.md` - -- Register custom exception handlers in your provider -- Use `HTTPException` for client errors, custom exceptions for domain errors -- Always log unexpected exceptions with context -- Never expose internal stack traces to clients - -### 5. Providers & Container → `rules/providers.md` - -- Bind services in `register()`, resolve dependencies in `boot()` -- Inject via the container — avoid `app().make()` in business logic -- Use facades for static-style access; add `.pyi` stubs for IDE support - -### 6. Console Commands → `rules/console-commands.md` - -- Extend `Command` from `fastapi_startkit.console` -- Use `option()` and `argument()` helpers for CLI args -- Register commands in your provider's `boot()` via `self.commands([...])` -- Keep `handle()` thin — delegate to services - -### 7. Testing → `rules/testing.md` - -- Use `pytest` with `asyncio_mode = "auto"` -- Reset the container singleton between tests -- Use `tmp_path` for filesystem isolation -- Mock external services — never hit real APIs in tests - -## How to Apply - -1. Identify the area of work and select relevant rule sections above -2. Check sibling files for existing patterns — follow those first -3. Verify API syntax with the framework docs for the installed version +- `asyncio_mode = "auto"` in `pyproject.toml` — all tests are async-capable. +- Reset the container singleton between tests (`Container._instance = original`). +- Use `tmp_path` for filesystem isolation; never hit real external APIs. diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md index 6a27d83c..6050f1b9 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md @@ -1,130 +1,19 @@ # HTTP Client -Rules for making outbound HTTP requests in FastAPI Startkit applications. - -## Rules - -### Always set explicit timeouts - -Set both `timeout` and `connect_timeout` on **every** request. Never rely on -the default (which may be infinite in some clients): +- Set explicit `timeout` and `connect_timeout` on every request — never rely on defaults. +- Retry on `{429, 502, 503, 504}` with exponential backoff; propagate on final attempt. +- Always call `response.raise_for_status()` or branch on `response.status_code` explicitly. +- Use `asyncio.gather()` for concurrent independent requests. +- Never hit real APIs in tests — mock with `respx`; use `assert_all_mocked=True` to catch strays. ```python import httpx -async with httpx.AsyncClient() as client: - response = await client.get( - "https://api.example.com/data", - timeout=httpx.Timeout(timeout=10.0, connect=5.0), - ) -``` - -For a shared client (e.g. in a provider), set defaults at construction time: - -```python -client = httpx.AsyncClient( +async with httpx.AsyncClient( timeout=httpx.Timeout(timeout=10.0, connect=5.0), base_url="https://api.example.com", -) -``` - -### Retry with exponential backoff for external APIs - -Do not let transient failures propagate immediately. Use exponential backoff -with jitter on retryable status codes (429, 502, 503, 504): - -```python -import asyncio -import httpx - -RETRYABLE = {429, 502, 503, 504} - -async def get_with_retry(url: str, max_retries: int = 3) -> httpx.Response: - async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client: - for attempt in range(max_retries + 1): - try: - response = await client.get(url) - if response.status_code not in RETRYABLE: - return response - except (httpx.ConnectError, httpx.TimeoutException): - if attempt == max_retries: - raise - - wait = 2 ** attempt # 1 s, 2 s, 4 s … - await asyncio.sleep(wait) - - raise RuntimeError("Max retries exceeded") -``` - -### Check response status explicitly - -Always check the response status. Use `raise_for_status()` as a safe default -or inspect the code explicitly when you need to branch on specific errors: - -```python -# Safe default — raises httpx.HTTPStatusError on 4xx / 5xx -response = await client.get(url) -response.raise_for_status() -data = response.json() - -# Explicit branching -if response.status_code == 404: - return None -if response.status_code == 429: - raise RateLimitedError("External API rate limited") -response.raise_for_status() -``` - -Never silently ignore a failed status code. - -### Use asyncio.gather() for concurrent independent requests - -When multiple requests are independent of each other, run them concurrently -instead of sequentially: - -```python -import asyncio -import httpx - -async def fetch_all(urls: list[str]) -> list[dict]: - async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client: - responses = await asyncio.gather( - *[client.get(url) for url in urls], - return_exceptions=True, - ) - results = [] - for r in responses: - if isinstance(r, Exception): - raise r - r.raise_for_status() - results.append(r.json()) - return results -``` - -### Mock HTTP clients in tests - -Never hit real external APIs in tests. Use `respx` (for `httpx`) to intercept -and fake responses: - -```python -import respx -import httpx - -@respx.mock -async def test_fetch_user(): - respx.get("https://api.example.com/users/1").mock( - return_value=httpx.Response(200, json={"id": 1, "name": "Alice"}) - ) - result = await fetch_user(1) - assert result["name"] == "Alice" -``` - -For stray-request protection (equivalent to Laravel's `preventStrayRequests`), -use `respx.mock(assert_all_mocked=True)` to raise on any unmocked call: - -```python -@respx.mock(assert_all_mocked=True) -async def test_no_stray_requests(): - # Any unmocked httpx call will raise AssertionError - ... +) as client: + response = await client.get("/data") + response.raise_for_status() + return response.json() ``` diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py index 50a0f07f..d0df441a 100644 --- a/fastapi_startkit/tests/skills/test_commands.py +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -144,7 +144,7 @@ def test_sync_claude_creates_nested_rule_file(self, tmp_path, app): app.bind("rules.registry", RulesRegistry(app)) code, _ = _run(RulesSyncCommand, app, ["--target=claude"]) assert code == 0 - dest = tmp_path / ".claude" / "skills" / "fastapi-best-practices" / "rules" / "http-client.md" + dest = tmp_path / ".claude" / "rules" / "fastapi-best-practices" / "http-client.md" assert dest.exists() assert "Always set timeout." in dest.read_text() @@ -163,12 +163,12 @@ def test_sync_all_writes_both(self, tmp_path, app): app.bind("rules.registry", RulesRegistry(app)) code, _ = _run(RulesSyncCommand, app, ["--target=all"]) assert code == 0 - assert (tmp_path / ".claude" / "skills" / "orm-best-practices" / "rules" / "queries.md").exists() + assert (tmp_path / ".claude" / "rules" / "orm-best-practices" / "queries.md").exists() assert (tmp_path / "GEMINI.md").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" / "skills" / "fastapi-best-practices" / "rules" / "old-rule.md" + 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 @@ -177,20 +177,18 @@ def test_sync_prune_removes_stale_rule_within_skill(self, tmp_path, app): _run(RulesSyncCommand, app, ["--target=claude", "--prune"]) assert not stale.exists() - def test_prune_removes_skill_dir_removes_its_rules_too(self, tmp_path, app): - """When ClaudeAdapter prunes a skill, its nested rules/ are gone too.""" - from fastapi_startkit.skills.adapters.claude import ClaudeAdapter - # Simulate a previously synced skill with a nested rule - old_skill = tmp_path / ".claude" / "skills" / "dead-skill" - old_rule = old_skill / "rules" / "some-rule.md" - old_rule.parent.mkdir(parents=True) - old_rule.write_text("rule content") - (old_skill / "SKILL.md").write_text("---\nname: dead-skill\n---\n") - - # Prune with an empty skills list removes the whole dir (including rules/) - adapter = ClaudeAdapter(base_path=tmp_path) + 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") + + # Prune with an empty rules list — stale dir should be removed + adapter = ClaudeRulesAdapter(base_path=tmp_path) messages = adapter.prune([]) - assert not old_skill.exists() + assert not stale_dir.exists() assert any("Pruned" in m for m in messages) def test_no_rules_exits_gracefully(self, tmp_path, app): @@ -225,7 +223,7 @@ def test_list_shows_rules(self, tmp_path, app): def test_list_shows_synced_status(self, tmp_path, app): _write_rule_md(tmp_path, "fastapi-best-practices", "http-client") - dest = tmp_path / ".claude" / "skills" / "fastapi-best-practices" / "rules" / "http-client.md" + dest = tmp_path / ".claude" / "rules" / "fastapi-best-practices" / "http-client.md" dest.parent.mkdir(parents=True) dest.write_text("synced content") app.bind("rules.registry", RulesRegistry(app)) From 11bad176d1d546a1883d679b5c6020687842b6ac Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 18:25:46 -0700 Subject: [PATCH 08/11] feat(skills): add fastapi-startkit skill stub, remove fastapi-best-practices + core.html Replace the placeholder fastapi-best-practices stub with the user-authored fastapi-startkit SKILL.md covering routing, controller shape, resource() shortcut, ORM model + controller usage, Pydantic request classes, JsonApiResource responses, and the action pattern. Delete skills/stubs/core.html (zero code references, leftover junk). Update SkillsServiceProvider.publishes() to reference the new skill only. Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/skills/provider.py | 9 +- .../skill/fastapi-best-practices/SKILL.md | 40 ------- .../rules/http-client.md | 19 --- .../skill/fastapi-startkit/SKILL.md | 109 ++++++++++++++++++ .../fastapi_startkit/skills/stubs/core.html | 85 -------------- 5 files changed, 112 insertions(+), 150 deletions(-) delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-startkit/SKILL.md delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/core.html diff --git a/fastapi_startkit/src/fastapi_startkit/skills/provider.py b/fastapi_startkit/src/fastapi_startkit/skills/provider.py index 5b449c29..dcdbc7cc 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/provider.py @@ -17,8 +17,7 @@ class SkillsServiceProvider(Provider): artisan provider:publish --provider=skills This copies: - - .ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md - - .ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md + - .ai/fastapi-startkit/skill/fastapi-startkit/SKILL.md """ provider_key = "skills" @@ -41,13 +40,11 @@ def boot(self) -> None: RulesListCommand, ]) - _skill_stub = _STUBS_DIR / ".ai" / "fastapi-startkit" / "skill" / "fastapi-best-practices" + _skill_stub = _STUBS_DIR / ".ai" / "fastapi-startkit" / "skill" / "fastapi-startkit" self.publishes( { str(_skill_stub / "SKILL.md"): - ".ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md", - str(_skill_stub / "rules" / "http-client.md"): - ".ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md", + ".ai/fastapi-startkit/skill/fastapi-startkit/SKILL.md", }, tag="skills", ) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md deleted file mode 100644 index 10037216..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: fastapi-best-practices -description: Apply when writing or reviewing FastAPI Startkit code — routing, ORM, providers, commands, validation, and testing. ---- - -## Routing - -- Use `Router` from `fastapi_startkit.fastapi`, not FastAPI's `APIRouter` directly. -- Use `router.resource(name, controller)` for CRUD; scope with `only=` or `excepts=`. -- Group routes by access level in separate `Router` instances (`guest`, `auth`). - -## ORM - -- All DB operations are `async`/`await` — never call ORM methods synchronously. -- Eager-load relationships to prevent N+1 queries. -- Use `exists()` to check presence without fetching rows. -- `chunk()` for large result sets. - -## Providers - -- Bind services in `register()`. Resolve dependencies only in `boot()`. -- Never call `app().make()` in business logic — inject via the container. - -## Console commands - -- Extend `Command` from `fastapi_startkit.console`. -- Register in provider `boot()` via `self.commands([...])`. -- Keep `handle()` thin — delegate to services. - -## Validation & errors - -- Pydantic models for all request bodies; declare `response_model` on every endpoint. -- Register custom exception handlers in a provider, not inline in routes. -- Never expose stack traces to clients. - -## Testing - -- `asyncio_mode = "auto"` in `pyproject.toml` — all tests are async-capable. -- Reset the container singleton between tests (`Container._instance = original`). -- Use `tmp_path` for filesystem isolation; never hit real external APIs. diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md deleted file mode 100644 index 6050f1b9..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-best-practices/rules/http-client.md +++ /dev/null @@ -1,19 +0,0 @@ -# HTTP Client - -- Set explicit `timeout` and `connect_timeout` on every request — never rely on defaults. -- Retry on `{429, 502, 503, 504}` with exponential backoff; propagate on final attempt. -- Always call `response.raise_for_status()` or branch on `response.status_code` explicitly. -- Use `asyncio.gather()` for concurrent independent requests. -- Never hit real APIs in tests — mock with `respx`; use `assert_all_mocked=True` to catch strays. - -```python -import httpx - -async with httpx.AsyncClient( - timeout=httpx.Timeout(timeout=10.0, connect=5.0), - base_url="https://api.example.com", -) as client: - response = await client.get("/data") - response.raise_for_status() - return response.json() -``` diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-startkit/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-startkit/SKILL.md new file mode 100644 index 00000000..dfa2b418 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-startkit/SKILL.md @@ -0,0 +1,109 @@ +--- +name: fastapi-startkit +description: Routing, controllers, ORM, requests, resources, and action pattern for fastapi-startkit applications. +--- + +# Fastapi's Routing + +### Fastapi Startkit's Router +```python +# routes/web.py +from fastapi_startkit.fastapi import Router + +router = Router() +``` + +and use the crud resources, for example +```python +router.post("/users", users_controller.store) +router.put("/users/{user_id}", users_controller.update) +router.patch("/users/{user_id}", users_controller.patch) +router.delete("/users", users_controller.destroy) +``` + +the controller will look like +```python +# app/http/controllers/users_controller.py +async def index(request: Request): + pass + +async def show(user_id: int): + pass + +async def store(data: UserSchema): + pass + +async def update(user_id: int, data: UserSchema): + pass + +async def destroy(user_id: int): + pass +``` + +or use the resource function as: +```python +router.resource("users", users_controller, excepts=['create', 'edit']) +``` + +## ORM +```python +# app/models/user.py +from fastapi_startkit.masoniteorm import Model + +class User(Model): + id: int + name: str + email: str + metadata: dict +``` + +and use the orm as: +```python +# app/http/controllers/users_controller.py +from app.models import User + +async def store(request: UserStoreRequest): + user = User.create(request.model_dump()) + ... +``` + +the `UserStoreRequest` will look like: +```python +# app/http/requests/user_store_request.py +from pydantic import BaseModel + +class UserStoreRequest(BaseModel): + name: str +``` + +and use JsonApiResource to return JSON response from the controller: +```python +from fastapi_startkit.resources import JsonApiResource + +# 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) +``` + +## Architecture + +use the action pattern to write complex logic. +```python +# app/actions/user_actions.py +from app.models import User + +class UserStoreAction: + def __init__(self, request: UserStoreRequest): + self.request = request + + @staticmethod + def prepare(request: UserStoreRequest) -> 'UserStoreAction': + return UserStoreAction(request) + + def handle(self) -> JsonResource[User]: + user = User.create(self.request.model_dump()) + return JsonResource(user) +``` diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/core.html b/fastapi_startkit/src/fastapi_startkit/skills/stubs/core.html deleted file mode 100644 index 3001b54d..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/core.html +++ /dev/null @@ -1,85 +0,0 @@ - - - -
-Use `Router` from `fastapi_startkit.fastapi` to register routes: - -```python -from fastapi_startkit.fastapi import Router - -router = Router() -router.get("/items", list_items) -router.post("/items", create_item) -router.resource("users", UsersController) -``` - -Group routes by access level using separate `Router` instances and -pass them to `app.fastapi` via your RouteServiceProvider. -
- -
-All ORM operations are async. Basic patterns: - -```python -# Fetch all -users = await User.all() - -# Filter -active = await User.where("active", True).get() - -# First or fail -user = await User.find_or_fail(user_id) - -# Create -user = await User.create(name="Alice", email="alice@example.com") - -# Relationships -posts = await user.posts().get() -``` - -Use `exists()` to check without fetching rows: -```python -if await User.where("email", email).exists(): - raise ValidationError("Email taken") -``` -
- -
-Extend `Command` from `fastapi_startkit.console`: - -```python -from fastapi_startkit.console import Command -from cleo.helpers import option, argument - -class GreetCommand(Command): - name = "greet" - description = "Greet a user by name." - arguments = [argument("name", description="Name to greet")] - - def handle(self): - self.line(f"Hello, {self.argument('name')}!") -``` - -Register in a provider: -```python -def boot(self): - self.commands([GreetCommand]) -``` - -Run with: `artisan greet Alice` -
From 1e8a5ea81aded621682861c52e2db5009deee69f Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 18:29:17 -0700 Subject: [PATCH 09/11] refactor(skills): flatten SKILLS_BASE_PATH to .ai/fastapi-startkit/ Drop the extra skill/ level from the discovery path. Skills now live at .ai/fastapi-startkit//SKILL.md (was .ai/fastapi-startkit/skill//SKILL.md). - SKILLS_BASE_PATH updated in registry.py and rules/registry.py - Stub file moved from stubs/.ai/.../skill/fastapi-startkit/ to stubs/.ai/.../fastapi-startkit/ accordingly - SkillsServiceProvider.publishes() destination updated - Test path assertion and docstrings updated ruff + pytest: 49/49 green Co-Authored-By: Claude Sonnet 4.6 --- .../src/fastapi_startkit/skills/__init__.py | 2 +- .../src/fastapi_startkit/skills/provider.py | 6 +++--- .../src/fastapi_startkit/skills/registry.py | 15 +++++++++------ .../src/fastapi_startkit/skills/rules/registry.py | 8 ++++---- .../{skill => }/fastapi-startkit/SKILL.md | 0 fastapi_startkit/tests/skills/test_registry.py | 4 ++-- 6 files changed, 19 insertions(+), 16 deletions(-) rename fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/{skill => }/fastapi-startkit/SKILL.md (100%) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py index 4d66267b..4e307a7d 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py @@ -1,7 +1,7 @@ """fastapi_startkit.skills — AI skill & rules registry and adapters. Skills and their nested rules both live under: - .ai/fastapi-startkit/skill// + .ai/fastapi-startkit// SKILL.md rules/ .md diff --git a/fastapi_startkit/src/fastapi_startkit/skills/provider.py b/fastapi_startkit/src/fastapi_startkit/skills/provider.py index dcdbc7cc..52b1091b 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/provider.py @@ -17,7 +17,7 @@ class SkillsServiceProvider(Provider): artisan provider:publish --provider=skills This copies: - - .ai/fastapi-startkit/skill/fastapi-startkit/SKILL.md + - .ai/fastapi-startkit/fastapi-startkit/SKILL.md """ provider_key = "skills" @@ -40,11 +40,11 @@ def boot(self) -> None: RulesListCommand, ]) - _skill_stub = _STUBS_DIR / ".ai" / "fastapi-startkit" / "skill" / "fastapi-startkit" + _skill_stub = _STUBS_DIR / ".ai" / "fastapi-startkit" / "fastapi-startkit" self.publishes( { str(_skill_stub / "SKILL.md"): - ".ai/fastapi-startkit/skill/fastapi-startkit/SKILL.md", + ".ai/fastapi-startkit/fastapi-startkit/SKILL.md", }, tag="skills", ) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/registry.py index 86580671..bd679b78 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/registry.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/registry.py @@ -1,10 +1,13 @@ -"""SkillRegistry — discovers canonical skills from ``.ai/fastapi-startkit/skill/``. +"""SkillRegistry — discovers canonical skills from ``.ai/fastapi-startkit/``. -Skills follow the same convention as Laravel Boost:: +Each skill lives in a named subdirectory (or a ``SKILL.md`` directly under the +base) and carries a YAML front-matter block:: - .ai/fastapi-startkit/skill/ - fastapi-best-practices/ + .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. """ @@ -19,7 +22,7 @@ from fastapi_startkit.application import Application #: Base directory for framework skills (relative to project root). -SKILLS_BASE_PATH = Path(".ai") / "fastapi-startkit" / "skill" +SKILLS_BASE_PATH = Path(".ai") / "fastapi-startkit" @dataclass @@ -66,7 +69,7 @@ def _parse_frontmatter(text: str) -> tuple[dict, str]: class SkillRegistry: - """Loads Skill objects from .ai/fastapi-startkit/skill/*/SKILL.md.""" + """Loads Skill objects from .ai/fastapi-startkit/*/SKILL.md.""" def __init__(self, app: "Application") -> None: self._app = app diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py index 7abb94e5..68ca5498 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py @@ -2,8 +2,8 @@ Rules live **inside** their parent skill, mirroring the Laravel Boost convention:: - .ai/fastapi-startkit/skill/ - fastapi-best-practices/ + .ai/fastapi-startkit/ + fastapi-startkit/ SKILL.md rules/ http-client.md @@ -30,7 +30,7 @@ from fastapi_startkit.application import Application #: Root under which all skill (and nested rule) directories live. -SKILLS_BASE_PATH = Path(".ai") / "fastapi-startkit" / "skill" +SKILLS_BASE_PATH = Path(".ai") / "fastapi-startkit" @dataclass @@ -78,7 +78,7 @@ def _parse_frontmatter(text: str) -> tuple[dict, str]: class RulesRegistry: """Loads :class:`Rule` objects from nested ``rules/`` dirs inside each skill. - Scan path: ``{base_path}/.ai/fastapi-startkit/skill/*/rules/*.md`` + Scan path: ``{base_path}/.ai/fastapi-startkit/*/rules/*.md`` Usage:: diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-startkit/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi-startkit/SKILL.md similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-startkit/SKILL.md rename to fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi-startkit/SKILL.md diff --git a/fastapi_startkit/tests/skills/test_registry.py b/fastapi_startkit/tests/skills/test_registry.py index ccf24f44..af8633ac 100644 --- a/fastapi_startkit/tests/skills/test_registry.py +++ b/fastapi_startkit/tests/skills/test_registry.py @@ -1,4 +1,4 @@ -"""Tests for SkillRegistry reading from .ai/fastapi-startkit/skill/*/SKILL.md.""" +"""Tests for SkillRegistry reading from .ai/fastapi-startkit/*/SKILL.md.""" from __future__ import annotations @@ -125,4 +125,4 @@ def test_registry_get_returns_skill_by_name(tmp_path, app): def test_registry_skills_base_path_property(tmp_path, app): r = SkillRegistry(app) - assert r.skills_base_path == tmp_path / ".ai" / "fastapi-startkit" / "skill" + assert r.skills_base_path == tmp_path / ".ai" / "fastapi-startkit" From 0ac4767ff92c147d875f240e165d2207922d0f80 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 18:31:03 -0700 Subject: [PATCH 10/11] style(skills): apply ruff format to all skills source and test files Co-Authored-By: Claude Sonnet 4.6 --- .../fastapi_startkit/skills/adapters/claude.py | 1 + .../fastapi_startkit/skills/adapters/gemini.py | 2 +- .../fastapi_startkit/skills/commands/list.py | 4 +--- .../fastapi_startkit/skills/commands/sync.py | 3 +-- .../src/fastapi_startkit/skills/provider.py | 17 +++++++++-------- .../src/fastapi_startkit/skills/registry.py | 3 ++- .../skills/rules/adapters/claude.py | 8 ++------ .../skills/rules/adapters/gemini.py | 2 +- .../skills/rules/commands/list.py | 12 +++++------- .../fastapi_startkit/skills/rules/registry.py | 9 +++++---- fastapi_startkit/tests/skills/test_adapters.py | 3 +-- fastapi_startkit/tests/skills/test_commands.py | 7 ++++++- fastapi_startkit/tests/skills/test_registry.py | 2 ++ fastapi_startkit/uv.lock | 2 +- 14 files changed, 38 insertions(+), 37 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py index a9ef6a64..5420c62a 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py @@ -44,6 +44,7 @@ def prune(self, skills: Sequence[Skill]) -> list[str]: for child in sorted(skills_root.iterdir()): if child.is_dir() and child.name not in known_names: import shutil + shutil.rmtree(child) messages.append(f"[claude] Pruned .claude/skills/{child.name}/") return messages diff --git a/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py b/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py index 71ed9f6e..ddc1f6de 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py @@ -94,7 +94,7 @@ def _splice(original: str, section: str) -> str: if start_idx != -1 and end_idx != -1 and end_idx > start_idx: before = original[:start_idx] - after = original[end_idx + len(_MARKER_END):] + after = original[end_idx + len(_MARKER_END) :] return before + section + after else: # No markers yet — append diff --git a/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py index 4033b6fb..9eb9509b 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py @@ -50,9 +50,7 @@ def handle(self) -> int: 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(f" {skill.provider_key:<20} {skill.name:<25} {claude_status:<10} {gemini_status:<10} {desc}") self.line("") return 0 diff --git a/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py b/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py index 4745cd0a..dcbc99c3 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py @@ -54,8 +54,7 @@ def handle(self) -> int: if not skills: self.line( - "No skills found. Publish stubs first: " - "artisan provider:publish --provider=skills" + "No skills found. Publish stubs first: artisan provider:publish --provider=skills" ) return 0 diff --git a/fastapi_startkit/src/fastapi_startkit/skills/provider.py b/fastapi_startkit/src/fastapi_startkit/skills/provider.py index 52b1091b..09dba3d2 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/provider.py @@ -33,18 +33,19 @@ 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, - ]) + self.commands( + [ + SkillsSyncCommand, + SkillsListCommand, + RulesSyncCommand, + RulesListCommand, + ] + ) _skill_stub = _STUBS_DIR / ".ai" / "fastapi-startkit" / "fastapi-startkit" self.publishes( { - str(_skill_stub / "SKILL.md"): - ".ai/fastapi-startkit/fastapi-startkit/SKILL.md", + str(_skill_stub / "SKILL.md"): ".ai/fastapi-startkit/fastapi-startkit/SKILL.md", }, tag="skills", ) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/registry.py index bd679b78..e37d1bf0 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/registry.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/registry.py @@ -53,10 +53,11 @@ def _parse_frontmatter(text: str) -> tuple[dict, str]: return {}, text fm_text = "".join(lines[1:end_idx]) - body = "".join(lines[end_idx + 1:]) + body = "".join(lines[end_idx + 1 :]) try: import yaml + meta = yaml.safe_load(fm_text) or {} except ModuleNotFoundError: meta = {} diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py index 75917c88..a5abc57d 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py @@ -43,9 +43,7 @@ def render(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] 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" - ) + 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] @@ -69,9 +67,7 @@ def prune(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] 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}" - ) + 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() diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py index 6aa5e8a8..943c4a47 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py @@ -69,6 +69,6 @@ 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):] + 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/list.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py index 1e97be96..ed834141 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py @@ -25,7 +25,9 @@ def handle(self) -> int: rules = registry.discover() if not rules: - self.line("No rules found. Publish stubs first: artisan provider:publish --provider=skills") + self.line( + "No rules found. Publish stubs first: artisan provider:publish --provider=skills" + ) return 0 base_path: Path = self.container.base_path @@ -39,14 +41,10 @@ def handle(self) -> int: self.line(" " + "-" * (len(header) - 2)) for rule in rules: - claude_dest = ( - base_path / ".claude" / "rules" / rule.skill_name / f"{rule.name}.md" - ) + 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(f" {rule.skill_name:<28} {rule.name:<25} {claude_status:<10} {gemini_status:<10}") self.line("") return 0 diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py index 68ca5498..21818ee2 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py @@ -37,9 +37,9 @@ 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 + 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 = "" @@ -60,10 +60,11 @@ def _parse_frontmatter(text: str) -> tuple[dict, str]: return {}, text fm_text = "".join(lines[1:end_idx]) - body = "".join(lines[end_idx + 1:]) + body = "".join(lines[end_idx + 1 :]) try: import yaml # type: ignore[import] + meta = yaml.safe_load(fm_text) or {} except ModuleNotFoundError: meta = {} diff --git a/fastapi_startkit/tests/skills/test_adapters.py b/fastapi_startkit/tests/skills/test_adapters.py index adccd151..a43ce779 100644 --- a/fastapi_startkit/tests/skills/test_adapters.py +++ b/fastapi_startkit/tests/skills/test_adapters.py @@ -123,8 +123,7 @@ def test_render_idempotent(self, tmp_path): def test_render_preserves_content_outside_markers(self, tmp_path): gemini_md = tmp_path / "GEMINI.md" gemini_md.write_text( - "# My Project\n\nSome user content.\n\n" - + _MARKER_START + "\nold skills\n" + _MARKER_END + "\n\n" + "# My Project\n\nSome user content.\n\n" + _MARKER_START + "\nold skills\n" + _MARKER_END + "\n\n" "## Extra Section\n\nUser notes.\n" ) diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py index d0df441a..f152c237 100644 --- a/fastapi_startkit/tests/skills/test_commands.py +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -46,7 +46,7 @@ def _run(cmd_class, container, args=None): cmd = cmd_class() cmd.set_container(container) opts = {} - for arg in (args or []): + for arg in args or []: if arg.startswith("--"): k = arg.lstrip("-").split("=")[0] v = arg.split("=")[1] if "=" in arg else True @@ -62,6 +62,7 @@ def _run(cmd_class, container, args=None): # skills:sync # =========================================================================== + class TestSkillsSyncCommand: def test_sync_all_writes_claude_and_gemini(self, tmp_path, app): _write_skill_md(tmp_path, "orm-routing", "ORM routing") @@ -113,6 +114,7 @@ def test_command_name(self): # skills:list # =========================================================================== + class TestSkillsListCommand: def test_list_shows_skills(self, tmp_path, app): _write_skill_md(tmp_path, "fastapi-routing", "FastAPI routing") @@ -138,6 +140,7 @@ def test_command_name(self): # rules:sync (rules nested inside skills) # =========================================================================== + 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.") @@ -180,6 +183,7 @@ def test_sync_prune_removes_stale_rule_within_skill(self, tmp_path, app): 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) @@ -210,6 +214,7 @@ def test_command_name(self): # rules:list # =========================================================================== + class TestRulesListCommand: def test_list_shows_rules(self, tmp_path, app): _write_rule_md(tmp_path, "fastapi-best-practices", "http-client") diff --git a/fastapi_startkit/tests/skills/test_registry.py b/fastapi_startkit/tests/skills/test_registry.py index af8633ac..96e8ac10 100644 --- a/fastapi_startkit/tests/skills/test_registry.py +++ b/fastapi_startkit/tests/skills/test_registry.py @@ -40,6 +40,7 @@ def _write_skill(tmp_path: Path, name: str, description: str, body: str = "") -> # -- _parse_frontmatter -- + 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) @@ -62,6 +63,7 @@ def test_parse_frontmatter_unclosed_fence(): # -- SkillRegistry -- + 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().") diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 3ded87ed..54c7e7b3 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -593,7 +593,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.38.0" +version = "0.40.0" source = { editable = "." } dependencies = [ { name = "cleo" }, From 56649e08a700e82baed8fa9e8942bacd1164fd54 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 19:12:23 -0700 Subject: [PATCH 11/11] fix(broadcasting): replace deprecated get_event_loop() with asyncio.run() asyncio.get_event_loop().run_until_complete() raises RuntimeError when pytest-asyncio (asyncio_mode=auto) has already closed the loop. asyncio.run() always creates a fresh loop and is the correct modern API. Co-Authored-By: Claude Sonnet 4.6 --- fastapi_startkit/tests/broadcasting/test_reverb_server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/fastapi_startkit/tests/broadcasting/test_reverb_server.py b/fastapi_startkit/tests/broadcasting/test_reverb_server.py index 7f4184de..c0c95377 100644 --- a/fastapi_startkit/tests/broadcasting/test_reverb_server.py +++ b/fastapi_startkit/tests/broadcasting/test_reverb_server.py @@ -55,9 +55,7 @@ def test_broadcast_delivers_to_subscriber(server): # Broadcast from server side import asyncio - asyncio.get_event_loop().run_until_complete( - server.broadcast_to_channel("orders.1", "OrderShipped", {"order_id": 1}) - ) + asyncio.run(server.broadcast_to_channel("orders.1", "OrderShipped", {"order_id": 1})) msg = json.loads(ws.receive_text()) assert msg["event"] == "OrderShipped"