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
23 changes: 3 additions & 20 deletions fastapi_startkit/src/fastapi_startkit/skills/__init__.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,7 @@
"""fastapi_startkit.skills — AI skill & rules registry and adapters.

Skills and their nested rules both live under:
.ai/fastapi-startkit/<skill-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
from .registry import SkillRegistry
from .provider import AISkillProvider

__all__ = [
"Skill",
"SkillRegistry",
"SkillsServiceProvider",
"SKILLS_BASE_PATH",
"Rule",
"RulesRegistry",
"_parse_frontmatter",
"AISkillProvider",
]
14 changes: 11 additions & 3 deletions fastapi_startkit/src/fastapi_startkit/skills/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from abc import ABC, abstractmethod
from pathlib import Path
from typing import Sequence
from typing import Callable, Sequence

from fastapi_startkit.skills.registry import Skill

Expand All @@ -34,11 +34,19 @@ def __init__(self, base_path: Path | str | None = None) -> None:
# ------------------------------------------------------------------

@abstractmethod
def render(self, skills: Sequence[Skill]) -> list[str]:
def render(
self,
skills: Sequence[Skill],
force: bool = False,
confirm: Callable[..., bool] | None = None,
) -> list[str]:
"""Write *skills* to the target format.

Existing files are preserved unless *force* is true or *confirm*
returns true for the overwrite prompt — mirroring ``provider:publish``.

Returns a list of human-readable lines describing what was written
(suitable for printing in the ``skills:sync`` command).
(suitable for printing in the ``ai:skills`` command).
"""

@abstractmethod
Expand Down
53 changes: 31 additions & 22 deletions fastapi_startkit/src/fastapi_startkit/skills/adapters/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from pathlib import Path
from typing import Sequence
from typing import Callable, Sequence

from fastapi_startkit.skills.registry import Skill
from .base import BaseAdapter
Expand All @@ -13,8 +13,10 @@ 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.
YAML front-matter block followed by the original body. Publishing follows
the same rules as ``provider:publish``: a missing file is written, an
existing file is left untouched unless ``force`` is set or the caller
confirms the overwrite.
"""

name = "claude"
Expand All @@ -23,16 +25,38 @@ class ClaudeAdapter(BaseAdapter):
# Public API
# ------------------------------------------------------------------

def render(self, skills: Sequence[Skill]) -> list[str]:
def render(
self,
skills: Sequence[Skill],
force: bool = False,
confirm: Callable[..., bool] | None = None,
) -> list[str]:
messages: list[str] = []
for skill in skills:
dest = self._skill_path(skill.name)
rel = f".claude/skills/{skill.name}/SKILL.md"
content = self._build_content(skill)
written = self._write_idempotent(dest, content)
verb = "Synced" if written else "Unchanged"
messages.append(f"[claude] {verb} .claude/skills/{skill.name}/SKILL.md")

if dest.exists() and not force:
if not self._confirm_overwrite(confirm, rel):
messages.append(f"[claude] Skipped <comment>{rel}</comment> (already exists)")
continue
verb = "Overwrote"
else:
verb = "Overwrote" if dest.exists() else "Published"

dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding="utf-8")
messages.append(f"[claude] {verb} <info>{rel}</info>")
return messages

@staticmethod
def _confirm_overwrite(confirm: Callable[..., bool] | None, rel: str) -> bool:
"""Ask the caller whether to overwrite *rel*; default to no."""
if confirm is None:
return False
return bool(confirm(f" <comment>{rel}</comment> already exists. Overwrite?", default=False))

def prune(self, skills: Sequence[Skill]) -> list[str]:
"""Remove ``.claude/skills/<name>/`` dirs not represented in *skills*."""
messages: list[str] = []
Expand Down Expand Up @@ -65,18 +89,3 @@ def _build_content(skill: Skill) -> str:
lines.append(skill.body)
lines.append("")
return "\n".join(lines)

@staticmethod
def _write_idempotent(path: Path, content: str) -> bool:
"""Write *content* to *path* only if it differs.

Returns *True* when the file was (re)written, *False* when unchanged.
"""
if path.exists():
existing = path.read_text(encoding="utf-8")
if existing == content:
return False

path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return True
16 changes: 12 additions & 4 deletions fastapi_startkit/src/fastapi_startkit/skills/adapters/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from __future__ import annotations

from pathlib import Path
from typing import Sequence
from typing import Callable, Sequence

from fastapi_startkit.skills.registry import Skill
from .base import BaseAdapter
Expand All @@ -32,12 +32,20 @@ class GeminiAdapter(BaseAdapter):
# Public API
# ------------------------------------------------------------------

def render(self, skills: Sequence[Skill]) -> list[str]:
def render(
self,
skills: Sequence[Skill],
force: bool = False,
confirm: Callable[..., bool] | None = None,
) -> list[str]:
# GEMINI.md edits are confined to the skills marker block, so user
# content is never clobbered — force/confirm are accepted for a uniform
# adapter interface but the block is always kept in sync.
gemini_md = self.base_path / "GEMINI.md"
new_section = self._build_section(skills)
changed = self._update_file(gemini_md, new_section)
verb = "Synced" if changed else "Unchanged"
return [f"[gemini] {verb} GEMINI.md ({len(skills)} skill(s))"]
verb = "Updated" if changed else "Unchanged"
return [f"[gemini] {verb} <info>GEMINI.md</info> ({len(skills)} skill(s))"]

def prune(self, skills: Sequence[Skill]) -> list[str]:
"""For Gemini, pruning just re-renders with the current skill list.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Cleo commands for the skills module."""

from .sync import SkillsSyncCommand
from .list import SkillsListCommand
from .skills import SkillsCommand

__all__ = ["SkillsSyncCommand", "SkillsListCommand"]
__all__ = ["SkillsCommand"]
73 changes: 0 additions & 73 deletions fastapi_startkit/src/fastapi_startkit/skills/commands/list.py

This file was deleted.

80 changes: 80 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/skills/commands/skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""ai:skills — list or sync provider-declared AI skills.

Examples::

artisan ai:skills --list
artisan ai:skills --sync
artisan ai:skills --sync --target=claude --prune
"""

from __future__ import annotations

from cleo.helpers import option

from fastapi_startkit.console import Command


class SkillsCommand(Command):
name = "ai:skills"
description = "List or sync provider-declared AI skills."

options = [
option("list", "l", flag=True, description="List providers that declare skills (default)."),
option("sync", "s", flag=True, description="Publish skills and sync them to AI agents."),
option(
"target",
"t",
flag=False,
description="Sync target: claude | gemini | all. Prompts if omitted.",
),
option(
"prune",
None,
flag=True,
description="Remove skill files no longer declared by any provider.",
),
option(
"force",
"f",
flag=True,
description="Overwrite existing files without prompting.",
),
]

def handle(self) -> int:
from fastapi_startkit.skills.registry import SkillRegistry

registry: SkillRegistry = self.container.make("ai_skill.skills.registry")

# --target / --prune only make sense when syncing, so they imply --sync.
if self.option("sync") or self.option("prune") or self.option("target"):
return self._sync(registry)

return self._list(registry)

def _list(self, registry) -> int:
providers = registry.get_providers()

if providers.is_empty():
self.line("<comment>No skills found in any registered provider.</comment>")
return 0

self.info("Skill(s) found for the following provider(s):")
for index, provider_key in enumerate(providers, start=1):
self.info(f"{index}. {provider_key}")
return 0

def _sync(self, registry) -> int:
choices = ["all", "claude", "gemini"]
target = self.option("target")
if not target:
answer = self.choice("Which agent do you want to publish for?", choices, default=0)
# Interactive returns the value; non-interactive returns the default index.
target = choices[answer] if isinstance(answer, int) else answer
target = target.lower()
prune = bool(self.option("prune"))
force = bool(self.option("force"))

for message in registry.publish(target=target, prune=prune, force=force, confirm=self.confirm):
self.line(f" {message}")
return 0
Loading
Loading