Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/skills/__init__.py
Original file line number Diff line number Diff line change
@@ -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-name>/
SKILL.md
rules/
<rule-name>.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",
]
Original file line number Diff line number Diff line change
@@ -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"]
49 changes: 49 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py
Original file line number Diff line number Diff line change
@@ -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.
"""
82 changes: 82 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""ClaudeAdapter — renders skills into ``.claude/skills/<name>/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-name>/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/<name>/`` 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
102 changes: 102 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py
Original file line number Diff line number Diff line change
@@ -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
``<!-- skills:start -->`` and ``<!-- skills:end -->`` 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 = "<!-- skills:start -->"
_MARKER_END = "<!-- skills: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"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Cleo commands for the skills module."""

from .sync import SkillsSyncCommand
from .list import SkillsListCommand

__all__ = ["SkillsSyncCommand", "SkillsListCommand"]
73 changes: 73 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/skills/commands/list.py
Original file line number Diff line number Diff line change
@@ -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("<comment>No skills found in any registered provider.</comment>")
return 0

base_path: Path = self.container.base_path

self.line("")
self.line(f" <info>Found {len(skills)} skill(s):</info>")
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 "<!-- skills:start -->" in content else "pending"
Loading
Loading