Source code for repomatic.skills

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""The bundled Claude Code skills, as `list-skills` and the launchers show them.

The `skills` component of {data}`~repomatic.components.COMPONENTS` declares
which skills ship and where they land. This module reads what each skill says
about itself, its frontmatter, for the two places a person meets the skills: the
`list-skills` table, and the launcher command a closing hint or a pull request
body hands over.
"""

from __future__ import annotations

import re

from click_extra import ColumnSpec

from .bundle import get_data_content
from .components import COMPONENTS_BY_NAME, SKILL_FILENAME
from .frontmatter import split_frontmatter

AUDIT_SKILL: str = "repomatic-audit"
"""The bundled skill auditing a repository against the release it pins."""

RECOMMENDED_MODEL_RE = re.compile(r"Recommended model: (?P<model>\w+)\.")
"""The model recommendation a skill's `compatibility` field carries.

The Agent Skills spec has no `model:` field, so the recommendation rides in
`compatibility` as prose, and this is the one shape every bundled skill
writes it in. `tests/test_skills.py` holds each skill to it.
"""

SKILL_LIST_HEADER_DEFS: tuple[ColumnSpec, ...] = (
    ColumnSpec("phase", "Phase"),
    ColumnSpec("skill", "Skill"),
    ColumnSpec("description", "Description", max_width=60),
)
"""Column definitions for the `repomatic list-skills` table.

Lives beside {func}`skill_catalog`, whose triples these columns render, so the
two cannot drift apart. The columns mirror the hand-maintained roster of
`docs/agent-skills.md`, which the page renders this command right above.
"""

SKILL_PHASE_ORDER: tuple[str, ...] = (
    "Setup",
    "Development",
    "Quality",
    "Maintenance",
    "Release",
)
"""Canonical display order for lifecycle phases in `list-skills` output."""

SKILL_PHASES: dict[str, str] = {
    entry.file_id: entry.phase for entry in COMPONENTS_BY_NAME["skills"].files
}
"""Maps skill names to lifecycle phases for display grouping."""

UPGRADE_SKILL: str = "repomatic-upgrade"
"""The bundled skill reviewing what a newer upstream release lets a repo adopt."""


def _skill_frontmatter(skill_id: str) -> dict[str, object]:
    """Read the frontmatter of the bundled skill *skill_id*.

    :raises KeyError: When the registry ships no skill of that name.
    """
    entry = COMPONENTS_BY_NAME["skills"].entry(skill_id)
    if entry is None:
        msg = f"No bundled skill is named {skill_id!r}."
        raise KeyError(msg)
    # Each skill is a bundled folder, so reach past it for the entry point.
    meta, _body = split_frontmatter(
        get_data_content(f"{entry.source}/{SKILL_FILENAME}")
    )
    return meta


[docs] def skill_catalog() -> list[tuple[str, str, str]]: """Read every bundled skill's display metadata off its frontmatter. :return: One `(phase, name, description)` tuple per bundled skill, in {data}`SKILL_PHASE_ORDER`, then in registry order within a phase. The description's trailing period is stripped for table display. Phases are keyed by the registry `file_id`, not the frontmatter name, so a skill renamed in frontmatter still lands in its phase. """ skills = [] for skill_id, phase in SKILL_PHASES.items(): meta = _skill_frontmatter(skill_id) name = str(meta.get("name", skill_id)) description = str(meta.get("description", "")).removesuffix(".") skills.append((phase, name, description)) # A stable sort, so the registry order holds within each phase. return sorted(skills, key=lambda skill: SKILL_PHASE_ORDER.index(skill[0]))
[docs] def skill_launcher(skill_id: str, *arguments: str) -> str: """The shell command launching one bundled skill in a fresh Claude Code session. Rendered wherever the toolkit hands a maintainer a next step to run (`init`'s closing hint, a sync pull request body), so the invocation is spelled once. The model comes off the skill's own `compatibility` field, so a changed recommendation never leaves a stale `--model` behind. ```{note} Claude Code is the only agent the launcher targets today. The skill folder itself follows the Agent Skills spec and loads in any agent honouring it; only this one line is agent-specific, which keeps a second launcher a config key away rather than a rewrite. ``` :param skill_id: The registry `file_id` of a bundled skill. :param arguments: Extra words passed to the skill as `$ARGUMENTS`. :return: The command line, ready to paste into a shell. :raises KeyError: When the registry ships no skill named *skill_id*. """ meta = _skill_frontmatter(skill_id) match = RECOMMENDED_MODEL_RE.search(str(meta.get("compatibility", ""))) model = f" --model {match['model'].lower()}" if match else "" prompt = " ".join((f"/{skill_id}", *arguments)) return f"claude{model} '{prompt}'"