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..4e307a7d --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/__init__.py @@ -0,0 +1,24 @@ +"""fastapi_startkit.skills — AI skill & rules registry and adapters. + +Skills and their nested rules both live under: + .ai/fastapi-startkit// + SKILL.md + rules/ + .md + +Run ``artisan skills:sync`` to deploy skills, ``artisan rules:sync`` for rules. +""" + +from .registry import Skill, SkillRegistry, SKILLS_BASE_PATH, _parse_frontmatter +from .provider import SkillsServiceProvider +from .rules import Rule, RulesRegistry + +__all__ = [ + "Skill", + "SkillRegistry", + "SkillsServiceProvider", + "SKILLS_BASE_PATH", + "Rule", + "RulesRegistry", + "_parse_frontmatter", +] 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..5420c62a --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py @@ -0,0 +1,82 @@ +"""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..ddc1f6de --- /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..9eb9509b --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/commands/list.py @@ -0,0 +1,73 @@ +"""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..dcbc99c3 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/commands/sync.py @@ -0,0 +1,91 @@ +"""skills:sync — sync provider skills to one or more agent targets.""" + +from __future__ import annotations + +from cleo.helpers import option + +from fastapi_startkit.console import Command + + +class SkillsSyncCommand(Command): + """Sync provider skills into Claude Code / Gemini CLI skill files. + + Example usage:: + + artisan skills:sync + artisan skills:sync --target=claude + artisan skills:sync --target=gemini --prune + """ + + name = "skills:sync" + description = "Sync provider-declared skills to AI agent skill files." + + options = [ + option( + "target", + "t", + flag=False, + default="all", + description="Target adapter: claude | gemini | all (default: all)", + ), + option( + "prune", + None, + flag=True, + description="Remove skill files that are no longer declared by any provider.", + ), + ] + + def handle(self) -> int: + from fastapi_startkit.skills.registry import SkillRegistry + + registry: SkillRegistry = self.container.make("skills.registry") + skills = registry.discover() + + target = (self.option("target") or "all").lower() + do_prune = bool(self.option("prune")) + base_path = self.container.base_path + + adapters = self._resolve_adapters(target, base_path) + + if not adapters: + self.line(f"Unknown target '{target}'. Use: claude, gemini, all.") + return 1 + + if not skills: + self.line( + "No skills found. Publish stubs first: artisan provider:publish --provider=skills" + ) + return 0 + + self.line(f"Found {len(skills)} skill(s). Syncing to: {target}…") + self.line("") + + for adapter in adapters: + messages = adapter.render(skills) + for msg in messages: + self.line(f" {msg}") + + if do_prune: + prune_messages = adapter.prune(skills) + for msg in prune_messages: + self.line(f" {msg}") + + self.line("") + self.line("Done.") + return 0 + + @staticmethod + def _resolve_adapters(target: str, base_path) -> list: + from fastapi_startkit.skills.adapters import ClaudeAdapter, GeminiAdapter + + all_adapters = { + "claude": ClaudeAdapter, + "gemini": GeminiAdapter, + } + + if target == "all": + return [cls(base_path) for cls in all_adapters.values()] + + cls = all_adapters.get(target) + return [cls(base_path)] if cls else [] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/provider.py b/fastapi_startkit/src/fastapi_startkit/skills/provider.py new file mode 100644 index 00000000..09dba3d2 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/provider.py @@ -0,0 +1,51 @@ +"""SkillsServiceProvider — registers skills and rules into the application.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi_startkit.providers import Provider + +_STUBS_DIR = Path(__file__).parent / "stubs" + + +class SkillsServiceProvider(Provider): + """Service provider that bootstraps the skills and rules modules. + + Publish starter files with:: + + artisan provider:publish --provider=skills + + This copies: + - .ai/fastapi-startkit/fastapi-startkit/SKILL.md + """ + + provider_key = "skills" + + def register(self) -> None: + from fastapi_startkit.skills.registry import SkillRegistry + from fastapi_startkit.skills.rules.registry import RulesRegistry + + self.app.bind("skills.registry", SkillRegistry(self.app)) + self.app.bind("rules.registry", RulesRegistry(self.app)) + + 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, + ] + ) + + _skill_stub = _STUBS_DIR / ".ai" / "fastapi-startkit" / "fastapi-startkit" + self.publishes( + { + str(_skill_stub / "SKILL.md"): ".ai/fastapi-startkit/fastapi-startkit/SKILL.md", + }, + tag="skills", + ) 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..e37d1bf0 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/registry.py @@ -0,0 +1,126 @@ +"""SkillRegistry — discovers canonical skills from ``.ai/fastapi-startkit/``. + +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/ + 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. +""" + +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 + +#: Base directory for framework skills (relative to project root). +SKILLS_BASE_PATH = Path(".ai") / "fastapi-startkit" + + +@dataclass +class Skill: + """Canonical skill metadata parsed from a SKILL.md file.""" + + name: str + description: str + path: Path + provider_key: str = "fastapi-startkit" + body: str = field(default="", repr=False) + metadata: dict = field(default_factory=dict, repr=False) + + +def _parse_frontmatter(text: str) -> tuple[dict, str]: + """Parse YAML front-matter. Returns (meta_dict, body_str).""" + lines = text.splitlines(keepends=True) + if not lines or lines[0].strip() != "---": + return {}, text + + end_idx = None + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + end_idx = i + break + + if end_idx is None: + return {}, text + + fm_text = "".join(lines[1:end_idx]) + body = "".join(lines[end_idx + 1 :]) + + try: + import yaml + + meta = yaml.safe_load(fm_text) or {} + except ModuleNotFoundError: + meta = {} + for line in lines[1:end_idx]: + if ":" in line: + k, _, v = line.partition(":") + meta[k.strip()] = v.strip() + + return meta, body + + +class SkillRegistry: + """Loads Skill objects from .ai/fastapi-startkit/*/SKILL.md.""" + + def __init__(self, app: "Application") -> None: + self._app = app + self._skills: list[Skill] | None = None + + @property + def skills_base_path(self) -> Path: + return Path(self._app.base_path) / SKILLS_BASE_PATH + + def discover(self) -> list[Skill]: + if self._skills is not None: + return self._skills + + base = self.skills_base_path + if not base.is_dir(): + self._skills = [] + return self._skills + + self._skills = [] + for skill_md in sorted(base.rglob("SKILL.md")): + skill = self._load(skill_md) + if skill is not None: + self._skills.append(skill) + + return self._skills + + def get(self, name: str) -> "Skill | None": + return next((s for s in self.discover() if s.name == name), None) + + def reset(self) -> None: + self._skills = None + + @staticmethod + def _load(skill_md: Path) -> "Skill | None": + try: + text = skill_md.read_text(encoding="utf-8") + except OSError: + return None + + meta, body = _parse_frontmatter(text) + name = (meta.get("name") or skill_md.parent.name or "").strip() + if not name: + return None + + description = (meta.get("description") or "").strip() + extra = {k: v for k, v in meta.items() if k not in ("name", "description")} + return Skill( + name=name, + description=description, + path=skill_md, + body=body.strip(), + metadata=extra, + ) 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..ec91e0b8 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/__init__.py @@ -0,0 +1,8 @@ +"""Rules sub-system — per-topic rule files nested inside each skill directory. + +Layout: .ai/fastapi-startkit/skill//rules/.md +""" + +from .registry import Rule, RulesRegistry, SKILLS_BASE_PATH + +__all__ = ["Rule", "RulesRegistry", "SKILLS_BASE_PATH"] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/__init__.py 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..a5abc57d --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/claude.py @@ -0,0 +1,90 @@ +"""ClaudeRulesAdapter — deploys rules to ``.claude/rules//.md``. + +Claude Code reads rules **only** from ``.claude/rules/``. Rules written +anywhere else (e.g. ``.claude/skills//rules/``) are silently ignored. + +Output layout:: + + .claude/rules/ + fastapi-best-practices/ + http-client.md + validation.md + orm-best-practices/ + queries.md + +``prune()`` scans ``.claude/rules/`` independently of the skills adapter — +it removes individual rule files (and empty skill sub-dirs) that are no +longer present in the registry. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Sequence + +from fastapi_startkit.skills.adapters.base import BaseAdapter +from fastapi_startkit.skills.rules.registry import Rule + + +class ClaudeRulesAdapter(BaseAdapter): + """Writes each rule to ``.claude/rules//.md``. + + Writes are idempotent — the file is only (re)written when content changes. + ``prune()`` scans ``.claude/rules/`` and removes stale rule files and + empty skill sub-directories. It operates independently of + ``ClaudeAdapter.prune()``. + """ + + name = "claude-rules" + + def render(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] + messages: list[str] = [] + for rule in rules: + dest = self._rule_path(rule.skill_name, rule.name) + written = self._write_idempotent(dest, rule.body) + verb = "Synced" if written else "Unchanged" + messages.append(f"[claude] {verb} .claude/rules/{rule.skill_name}/{rule.name}.md") + return messages + + def prune(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] + """Remove stale rule files from ``.claude/rules/``. + + Iterates every ``/.md`` under ``.claude/rules/`` and + removes any file whose ``(skill_name, rule_name)`` pair is absent from + *rules*. Empty skill sub-directories are removed afterwards. + """ + messages: list[str] = [] + live = {(r.skill_name, r.name) for r in rules} + + rules_root = self.base_path / ".claude" / "rules" + if not rules_root.is_dir(): + return messages + + for skill_dir in sorted(rules_root.iterdir()): + if not skill_dir.is_dir(): + continue + for rule_file in sorted(skill_dir.glob("*.md")): + key = (skill_dir.name, rule_file.stem) + if key not in live: + rule_file.unlink() + messages.append(f"[claude] Pruned .claude/rules/{skill_dir.name}/{rule_file.name}") + # Remove empty skill sub-directory + if skill_dir.is_dir() and not any(skill_dir.iterdir()): + skill_dir.rmdir() + + return messages + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _rule_path(self, skill_name: str, rule_name: str) -> Path: + return self.base_path / ".claude" / "rules" / skill_name / f"{rule_name}.md" + + @staticmethod + def _write_idempotent(path: Path, content: str) -> bool: + if path.exists() and path.read_text(encoding="utf-8") == content: + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return True diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py new file mode 100644 index 00000000..943c4a47 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/adapters/gemini.py @@ -0,0 +1,74 @@ +"""GeminiRulesAdapter — splices rules into ``GEMINI.md``, grouped by skill.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Sequence + +from fastapi_startkit.skills.adapters.base import BaseAdapter +from fastapi_startkit.skills.rules.registry import Rule + +_MARKER_START = "" +_MARKER_END = "" + + +class GeminiRulesAdapter(BaseAdapter): + """Writes rules into the ```` block of ``GEMINI.md``. + + Rules are grouped under their parent skill as a second-level heading. + Content outside the markers is never modified. + """ + + name = "gemini-rules" + + def render(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] + gemini_md = self.base_path / "GEMINI.md" + section = self._build_section(rules) + changed = self._update_file(gemini_md, section) + verb = "Synced" if changed else "Unchanged" + return [f"[gemini] {verb} GEMINI.md rules section ({len(rules)} rule(s))"] + + def prune(self, rules: Sequence[Rule]) -> list[str]: # type: ignore[override] + """Re-render with the current (shorter) rule list — equivalent to pruning.""" + return self.render(rules) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _build_section(rules: Sequence[Rule]) -> str: + # Group rules by skill_name preserving order + by_skill: dict[str, list[Rule]] = {} + for rule in rules: + by_skill.setdefault(rule.skill_name, []).append(rule) + + parts = [_MARKER_START] + for skill_name, skill_rules in by_skill.items(): + parts.append(f"\n### {skill_name}\n") + for rule in skill_rules: + parts.append(f"\n#### {rule.name}\n") + if rule.description: + parts.append(f"{rule.description}\n") + if rule.body: + parts.append(f"\n{rule.body}\n") + parts.append(_MARKER_END) + return "\n".join(parts) + + def _update_file(self, path: Path, section: str) -> bool: + original = path.read_text(encoding="utf-8") if path.exists() else "" + new_content = self._splice(original, section) + if original == new_content: + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(new_content, encoding="utf-8") + return True + + @staticmethod + def _splice(original: str, section: str) -> str: + start = original.find(_MARKER_START) + end = original.find(_MARKER_END) + if start != -1 and end != -1 and end > start: + return original[:start] + section + original[end + len(_MARKER_END) :] + separator = "\n\n" if original and not original.endswith("\n\n") else "" + return original + separator + section + "\n" diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/__init__.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/__init__.py 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..ed834141 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/list.py @@ -0,0 +1,57 @@ +"""rules:list — list all rules nested inside skill directories.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi_startkit.console import Command + + +class RulesListCommand(Command): + """List all rule files and their deployment status. + + Example usage:: + + artisan rules:list + """ + + name = "rules:list" + description = "List skill-nested rules and their AI agent sync status." + + def handle(self) -> int: + from fastapi_startkit.skills.rules.registry import RulesRegistry + + registry: RulesRegistry = self.container.make("rules.registry") + rules = registry.discover() + + if not rules: + self.line( + "No rules found. Publish stubs first: artisan provider:publish --provider=skills" + ) + return 0 + + base_path: Path = self.container.base_path + + self.line("") + self.line(f" Found {len(rules)} rule(s):") + self.line("") + + header = f" {'SKILL':<28} {'RULE':<25} {'CLAUDE':<10} {'GEMINI':<10}" + self.line(header) + self.line(" " + "-" * (len(header) - 2)) + + for rule in rules: + claude_dest = base_path / ".claude" / "rules" / rule.skill_name / f"{rule.name}.md" + claude_status = "synced" if claude_dest.exists() else "pending" + gemini_status = self._gemini_status(base_path) + self.line(f" {rule.skill_name:<28} {rule.name:<25} {claude_status:<10} {gemini_status:<10}") + + self.line("") + return 0 + + @staticmethod + def _gemini_status(base_path: Path) -> str: + gemini_md = base_path / "GEMINI.md" + if not gemini_md.exists(): + return "pending" + return "synced" if "" in gemini_md.read_text(encoding="utf-8") else "pending" diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py new file mode 100644 index 00000000..2d3edc03 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/commands/sync.py @@ -0,0 +1,81 @@ +"""rules:sync — sync per-topic rule files to AI agent targets.""" + +from __future__ import annotations + +from cleo.helpers import option + +from fastapi_startkit.console import Command + + +class RulesSyncCommand(Command): + """Deploy ``rules/*.md`` files to AI agent rule directories. + + Example usage:: + + artisan rules:sync + artisan rules:sync --target=claude + artisan rules:sync --target=gemini --prune + """ + + name = "rules:sync" + description = "Sync rules/*.md files to AI agent rule directories." + + options = [ + option( + "target", + "t", + flag=False, + default="all", + description="Target adapter: claude | gemini | all (default: all)", + ), + option( + "prune", + None, + flag=True, + description="Remove rule files that no longer exist in rules/.", + ), + ] + + def handle(self) -> int: + from fastapi_startkit.skills.rules.registry import RulesRegistry + + registry: RulesRegistry = self.container.make("rules.registry") + rules = registry.discover() + + target = (self.option("target") or "all").lower() + do_prune = bool(self.option("prune")) + base_path = self.container.base_path + + adapters = self._resolve_adapters(target, base_path) + + if not adapters: + self.line(f"Unknown target '{target}'. Use: claude, gemini, all.") + return 1 + + if not rules: + self.line("No rules found in rules/. Create rules/*.md files first.") + return 0 + + self.line(f"Found {len(rules)} rule(s). Syncing to: {target}…") + self.line("") + + for adapter in adapters: + for msg in adapter.render(rules): + self.line(f" {msg}") + if do_prune: + for msg in adapter.prune(rules): + self.line(f" {msg}") + + self.line("") + self.line("Done.") + return 0 + + @staticmethod + def _resolve_adapters(target: str, base_path) -> list: + from fastapi_startkit.skills.rules.adapters import ClaudeRulesAdapter, GeminiRulesAdapter + + all_adapters = {"claude": ClaudeRulesAdapter, "gemini": GeminiRulesAdapter} + if target == "all": + return [cls(base_path) for cls in all_adapters.values()] + cls = all_adapters.get(target) + return [cls(base_path)] if cls else [] diff --git a/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py new file mode 100644 index 00000000..21818ee2 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/rules/registry.py @@ -0,0 +1,164 @@ +"""RulesRegistry — discovers per-skill rule files nested inside each skill directory. + +Rules live **inside** their parent skill, mirroring the Laravel Boost convention:: + + .ai/fastapi-startkit/ + fastapi-startkit/ + SKILL.md + rules/ + http-client.md + validation.md + orm-best-practices/ + SKILL.md + rules/ + queries.md + +Each rule is a plain Markdown file. The rule **name** is the file stem +(``http-client.md`` → ``"http-client"``). The owning skill is determined +from the parent directory name two levels up (``fastapi-best-practices``). + +Optional YAML front-matter may supply an explicit ``name`` or ``description``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi_startkit.application import Application + +#: Root under which all skill (and nested rule) directories live. +SKILLS_BASE_PATH = Path(".ai") / "fastapi-startkit" + + +@dataclass +class Rule: + """A per-topic coding rule nested inside a skill directory.""" + + name: str # file stem, e.g. "http-client" + skill_name: str # owning skill directory name, e.g. "fastapi-best-practices" + path: Path # absolute path to the .md source file + body: str = field(default="", repr=False) + description: str = "" + + +def _parse_frontmatter(text: str) -> tuple[dict, str]: + """Parse optional YAML front-matter; return ``(meta, body)``.""" + lines = text.splitlines(keepends=True) + if not lines or lines[0].strip() != "---": + return {}, text + + end_idx: int | None = None + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + end_idx = i + break + + if end_idx is None: + return {}, text + + fm_text = "".join(lines[1:end_idx]) + body = "".join(lines[end_idx + 1 :]) + + try: + import yaml # type: ignore[import] + + meta = yaml.safe_load(fm_text) or {} + except ModuleNotFoundError: + meta = {} + for line in lines[1:end_idx]: + if ":" in line: + k, _, v = line.partition(":") + meta[k.strip()] = v.strip() + + return meta, body + + +class RulesRegistry: + """Loads :class:`Rule` objects from nested ``rules/`` dirs inside each skill. + + Scan path: ``{base_path}/.ai/fastapi-startkit/*/rules/*.md`` + + Usage:: + + from fastapi_startkit.application import app + registry = app().make("rules.registry") + rules = registry.discover() + rules_for_skill = registry.for_skill("fastapi-best-practices") + """ + + def __init__(self, app: "Application") -> None: + self._app = app + self._rules: list[Rule] | None = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + @property + def skills_base_path(self) -> Path: + """Absolute path to the skill root (parent of all skill dirs).""" + return Path(self._app.base_path) / SKILLS_BASE_PATH + + def discover(self) -> list[Rule]: + """Return (and cache) all rules found across all skill directories.""" + if self._rules is not None: + return self._rules + + base = self.skills_base_path + if not base.is_dir(): + self._rules = [] + return self._rules + + self._rules = [] + for skill_dir in sorted(base.iterdir()): + if not skill_dir.is_dir(): + continue + rules_dir = skill_dir / "rules" + if not rules_dir.is_dir(): + continue + for md_file in sorted(rules_dir.glob("*.md")): + rule = self._load(md_file, skill_name=skill_dir.name) + if rule is not None: + self._rules.append(rule) + + return self._rules + + def for_skill(self, skill_name: str) -> list[Rule]: + """Return all rules belonging to *skill_name*.""" + return [r for r in self.discover() if r.skill_name == skill_name] + + def get(self, name: str, skill_name: str | None = None) -> Rule | None: + """Return the first :class:`Rule` matching *name*, optionally scoped to *skill_name*.""" + candidates = self.for_skill(skill_name) if skill_name else self.discover() + return next((r for r in candidates if r.name == name), None) + + def reset(self) -> None: + """Invalidate the discovery cache.""" + self._rules = None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _load(md_file: Path, skill_name: str) -> Rule | None: + try: + text = md_file.read_text(encoding="utf-8") + except OSError: + return None + + meta, body = _parse_frontmatter(text) + name = (meta.get("name") or md_file.stem or "").strip() + if not name: + return None + + return Rule( + name=name, + skill_name=skill_name, + path=md_file, + body=body.strip() if body.strip() else text.strip(), + description=(meta.get("description") or "").strip(), + ) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi-startkit/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi-startkit/SKILL.md new file mode 100644 index 00000000..dfa2b418 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/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/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" 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..a43ce779 --- /dev/null +++ b/fastapi_startkit/tests/skills/test_adapters.py @@ -0,0 +1,178 @@ +"""Tests for ClaudeAdapter and GeminiAdapter (task #140).""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi_startkit.skills.registry import Skill +from fastapi_startkit.skills.adapters.claude import ClaudeAdapter +from fastapi_startkit.skills.adapters.gemini import GeminiAdapter, _MARKER_START, _MARKER_END + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_skill(name: str, description: str = "A test skill.", body: str = "") -> Skill: + return Skill(name=name, description=description, path=Path("/dev/null"), provider_key="test", body=body) + + +# =========================================================================== +# ClaudeAdapter +# =========================================================================== + + +class TestClaudeAdapter: + def test_render_creates_skill_file(self, tmp_path): + adapter = ClaudeAdapter(base_path=tmp_path) + skills = [make_skill("my-skill", "Does stuff")] + + messages = adapter.render(skills) + + skill_file = tmp_path / ".claude" / "skills" / "my-skill" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "name: my-skill" in content + assert "description: Does stuff" in content + assert any("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..f152c237 --- /dev/null +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -0,0 +1,244 @@ +"""Tests for skills:sync, skills:list, rules:sync, rules:list commands.""" + +from __future__ import annotations + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container +from fastapi_startkit.skills.registry import SkillRegistry, SKILLS_BASE_PATH +from fastapi_startkit.skills.rules.registry import RulesRegistry +from fastapi_startkit.skills.commands.sync import SkillsSyncCommand +from fastapi_startkit.skills.commands.list import SkillsListCommand +from fastapi_startkit.skills.rules.commands.sync import RulesSyncCommand +from fastapi_startkit.skills.rules.commands.list import RulesListCommand + + +@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 _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", + ) + + +def _write_rule_md(tmp_path, skill_name, rule_name, body="Rule body."): + """Write a rule nested inside a skill directory.""" + rules_dir = tmp_path / SKILLS_BASE_PATH / skill_name / "rules" + rules_dir.mkdir(parents=True, exist_ok=True) + (rules_dir / f"{rule_name}.md").write_text(body, encoding="utf-8") + + +def _run(cmd_class, container, args=None): + cmd = cmd_class() + cmd.set_container(container) + opts = {} + for arg in args or []: + if arg.startswith("--"): + k = arg.lstrip("-").split("=")[0] + v = arg.split("=")[1] if "=" in arg else True + opts[k] = v + lines = [] + cmd.option = lambda k, default=None: opts.get(k, default) + cmd.line = lambda msg, *a, **kw: lines.append(msg) + cmd.info = lambda msg, *a, **kw: lines.append(msg) + return cmd.handle(), lines + + +# =========================================================================== +# 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") + app.bind("skills.registry", SkillRegistry(app)) + code, _ = _run(SkillsSyncCommand, app, ["--target=all"]) + assert code == 0 + assert (tmp_path / ".claude" / "skills" / "orm-routing" / "SKILL.md").exists() + assert (tmp_path / "GEMINI.md").exists() + + def test_sync_claude_only(self, tmp_path, app): + _write_skill_md(tmp_path, "console-commands", "Commands") + app.bind("skills.registry", SkillRegistry(app)) + _run(SkillsSyncCommand, app, ["--target=claude"]) + assert (tmp_path / ".claude" / "skills" / "console-commands" / "SKILL.md").exists() + assert not (tmp_path / "GEMINI.md").exists() + + def test_sync_gemini_only(self, tmp_path, app): + _write_skill_md(tmp_path, "fastapi-routing", "Routing") + app.bind("skills.registry", SkillRegistry(app)) + _run(SkillsSyncCommand, app, ["--target=gemini"]) + assert not (tmp_path / ".claude").exists() + assert (tmp_path / "GEMINI.md").exists() + + def test_sync_unknown_target_returns_error(self, tmp_path, app): + app.bind("skills.registry", SkillRegistry(app)) + code, lines = _run(SkillsSyncCommand, app, ["--target=codex"]) + assert code == 1 + + def test_sync_no_skills_exits_gracefully(self, tmp_path, app): + app.bind("skills.registry", SkillRegistry(app)) + code, lines = _run(SkillsSyncCommand, app) + assert code == 0 + assert any("No skills" in l for l in lines) + + def test_sync_prune_removes_old_skills(self, tmp_path, app): + old_dir = tmp_path / ".claude" / "skills" / "old-skill" + old_dir.mkdir(parents=True) + (old_dir / "SKILL.md").write_text("old") + _write_skill_md(tmp_path, "new-skill", "New") + app.bind("skills.registry", SkillRegistry(app)) + _run(SkillsSyncCommand, app, ["--target=claude", "--prune"]) + assert not old_dir.exists() + + def test_command_name(self): + assert SkillsSyncCommand().name == "skills:sync" + + +# =========================================================================== +# skills:list +# =========================================================================== + + +class TestSkillsListCommand: + def test_list_shows_skills(self, tmp_path, app): + _write_skill_md(tmp_path, "fastapi-routing", "FastAPI routing") + _write_skill_md(tmp_path, "orm-queries", "ORM queries") + app.bind("skills.registry", SkillRegistry(app)) + code, lines = _run(SkillsListCommand, app) + assert code == 0 + out = "\n".join(lines) + assert "fastapi-routing" in out + assert "orm-queries" in out + + 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 (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.") + app.bind("rules.registry", RulesRegistry(app)) + code, _ = _run(RulesSyncCommand, app, ["--target=claude"]) + assert code == 0 + dest = tmp_path / ".claude" / "rules" / "fastapi-best-practices" / "http-client.md" + assert dest.exists() + assert "Always set timeout." in dest.read_text() + + def test_sync_gemini_updates_gemini_md(self, tmp_path, app): + _write_rule_md(tmp_path, "fastapi-best-practices", "http-client", "Always set timeout.") + app.bind("rules.registry", RulesRegistry(app)) + code, _ = _run(RulesSyncCommand, app, ["--target=gemini"]) + assert code == 0 + content = (tmp_path / "GEMINI.md").read_text() + assert "" in content + assert "http-client" in content + assert "fastapi-best-practices" in content + + def test_sync_all_writes_both(self, tmp_path, app): + _write_rule_md(tmp_path, "orm-best-practices", "queries", "Use async ORM.") + app.bind("rules.registry", RulesRegistry(app)) + code, _ = _run(RulesSyncCommand, app, ["--target=all"]) + assert code == 0 + 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" / "rules" / "fastapi-best-practices" / "old-rule.md" + stale.parent.mkdir(parents=True) + stale.write_text("stale") + # Only http-client is in the registry now + _write_rule_md(tmp_path, "fastapi-best-practices", "http-client", "body") + app.bind("rules.registry", RulesRegistry(app)) + _run(RulesSyncCommand, app, ["--target=claude", "--prune"]) + assert not stale.exists() + + def test_prune_removes_stale_rule_skill_subdir(self, tmp_path, app): + """ClaudeRulesAdapter.prune() scans .claude/rules/, not .claude/skills/.""" + from fastapi_startkit.skills.rules.adapters.claude import ClaudeRulesAdapter + + # Pre-populate .claude/rules/ with a stale skill subdir + stale_dir = tmp_path / ".claude" / "rules" / "dead-skill" + stale_dir.mkdir(parents=True) + (stale_dir / "old-rule.md").write_text("stale rule") + + # Prune with an empty rules list — stale dir should be removed + adapter = ClaudeRulesAdapter(base_path=tmp_path) + messages = adapter.prune([]) + assert not stale_dir.exists() + assert any("Pruned" in m for m in messages) + + def test_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, _ = _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, "fastapi-best-practices", "http-client") + _write_rule_md(tmp_path, "fastapi-best-practices", "validation") + app.bind("rules.registry", RulesRegistry(app)) + code, lines = _run(RulesListCommand, app) + assert code == 0 + out = "\n".join(lines) + assert "http-client" in out + assert "validation" in out + + def test_list_shows_synced_status(self, tmp_path, app): + _write_rule_md(tmp_path, "fastapi-best-practices", "http-client") + dest = tmp_path / ".claude" / "rules" / "fastapi-best-practices" / "http-client.md" + 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)) + _, 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 new file mode 100644 index 00000000..96e8ac10 --- /dev/null +++ b/fastapi_startkit/tests/skills/test_registry.py @@ -0,0 +1,130 @@ +"""Tests for SkillRegistry reading from .ai/fastapi-startkit/*/SKILL.md.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container +from fastapi_startkit.skills.registry import ( + SkillRegistry, + SKILLS_BASE_PATH, + _parse_frontmatter, +) + + +@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 _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 + assert {s.name for s in skills} == {"fastapi-routing", "orm-queries"} + + +def test_registry_returns_empty_when_skills_dir_missing(tmp_path, app): + assert SkillRegistry(app).discover() == [] + + +def test_registry_skill_path_points_to_skill_md(tmp_path, app): + path = _write_skill(tmp_path, "my-skill", "desc") + skills = SkillRegistry(app).discover() + assert skills[0].path == path + + +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_skill(tmp_path, "s", "d") + r = SkillRegistry(app) + assert r.discover() is r.discover() + + +def test_registry_reset_clears_cache(tmp_path, app): + _write_skill(tmp_path, "s", "d") + r = SkillRegistry(app) + r.discover() + r.reset() + assert r._skills is None + + +def test_registry_get_returns_none_for_unknown(tmp_path, app): + _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_skill(tmp_path, "console-commands", "Artisan console.") + skill = SkillRegistry(app).get("console-commands") + assert skill is not None + assert skill.name == "console-commands" + assert skill.provider_key == "fastapi-startkit" + + +def test_registry_skills_base_path_property(tmp_path, app): + r = SkillRegistry(app) + assert r.skills_base_path == tmp_path / ".ai" / "fastapi-startkit" 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" },