From 42d74ed54e29683c17a02112e7d2d722af779bd7 Mon Sep 17 00:00:00 2001 From: Max Dallabetta Date: Tue, 16 Jun 2026 16:11:27 +0200 Subject: [PATCH] add agent-skills infrastructure and installer --- .gitignore | 3 + pyproject.toml | 2 + skills/README.md | 89 ++++++++++++++ skills/install.py | 49 ++++++++ skills/installer/__init__.py | 30 +++++ skills/installer/core.py | 219 +++++++++++++++++++++++++++++++++++ skills/installer/tui.py | 204 ++++++++++++++++++++++++++++++++ 7 files changed, 596 insertions(+) create mode 100644 skills/README.md create mode 100644 skills/install.py create mode 100644 skills/installer/__init__.py create mode 100644 skills/installer/core.py create mode 100644 skills/installer/tui.py diff --git a/.gitignore b/.gitignore index 8bdc1dd16..295488de4 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,6 @@ dmypy.json # Pyre type checker .pyre/ + +# Installed agent skills (see skills/install.py) +.claude/ diff --git a/pyproject.toml b/pyproject.toml index 6d79a93da..8442f5bbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,8 @@ dev = [ "pytest~=7.2.2", "mypy==1.9.0", "ruff==0.15.6", + # skills installer dashboard (skills/installer/tui.py); needs Python >=3.8.1 (textual 6.2.x; 6.3+ requires >=3.9) + "textual>=6.2, <7", # type stubs "types-lxml", "types-python-dateutil>=2.8, <3", diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 000000000..cd424c9e2 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,89 @@ +# Agent skills + +Playbooks and skills meant to be **executed by AI coding agents**, not human contributors. + +The repo stays **agent-neutral**: we track the skill *sources* here, but we do **not** commit any +agent's config directory (`.claude/` is gitignored). Each developer installs the skills they want into +their own agent with the installer below. + +## Skills + +| Skill | What it does | +|-------|--------------| +| [`review-publisher`](review-publisher/SKILL.md) | Reviews a publisher PR (new publisher or parser-version change): crawls live articles to verify the extracted `ArticleBody` mirrors the real article, checks `VALID_UNTIL` / version bumps / `validate=False` / `free_access`, and drafts a single GitHub review. | + +`install.py` (the installer launcher) and the `installer/` package behind it, plus this +`README.md`, round out the folder. + +## Layout + +Each skill is a **self-contained folder** — a `SKILL.md`, the `PLAYBOOK.md` it points at, and any helper +scripts under `scripts/` — so the whole thing can be copied anywhere and still work. The installer copies +the **entire folder**, so anything you drop in ships automatically; if the folder has a `requirements.txt` +at its root, the installer also pip-installs it into your current interpreter. Install is **atomic**: if +that pip step fails, the copied folder is rolled back (the cell flashes `!`, then shows `·`) so an install +never half-lands — `✓` always means the skill *and* its deps are in. + +Two path conventions keep a copied folder working: + +- `SKILL.md` links to `PLAYBOOK.md` as a **sibling**, so the link survives being copied into `.claude/`. +- **`${CLAUDE_SKILL_DIR}` is substituted by Claude Code in `SKILL.md` content only** — it is *not* an + environment variable, and other bundled files are read verbatim. So `SKILL.md` states the skill's own + directory once (via the placeholder) and tells the agent to substitute it into the `` paths + the `PLAYBOOK.md` commands use. A bare `scripts/…` path would wrongly assume the working dir is the + skill dir; a `${CLAUDE_SKILL_DIR}` outside SKILL.md would expand to nothing in a shell. + +References from `PLAYBOOK.md` to *other* repo docs use **repo-root-relative** links +(e.g. `/docs/attribute_guidelines.md`): they resolve on GitHub and from the repo root, which is where +the review skill runs anyway. + +## Install + +The installer is a full-screen **status dashboard**. Run it with no arguments: + +```bash +python skills/install.py +``` + +It shows a live matrix of *skills* × *scopes* for the selected agent. Each row is a skill; the +**Project** and **User** columns show whether it's installed (`✓`) or not (`·`). Move the cursor with +`↑`/`↓`, then toggle a scope to act on the highlighted skill — `p` for project, `u` for user. Toggling +installs the skill if it's absent and uninstalls it if it's present; the matrix updates in place, so you +never have to re-run. `r` refreshes from disk and `q` quits. + +The two scopes: + +- **project** installs to `./.claude/skills/`. The skill is scoped to this repo, where it's relevant, + and never pushed (`.claude/` is in the committed `.gitignore`). +- **user** installs to `~/.claude/skills/`, available in every project. Nothing touches the repo. (The + review skill references repo files, so it only makes sense while your working directory is the Fundus + repo regardless.) + +The installer needs the `textual` package, which ships in the project's `dev` extra: + +```bash +pip install -e .[dev] +``` + +Re-install (toggle off, then on) after editing a skill source to refresh the installed copy. Restart +your agent so it picks up a newly installed skill. + +## Supported agents + +| Agent | Status | Target | +|-------|--------|--------| +| **Claude Code** (`claude`) | supported | `.claude/skills//` | +| Codex, Cursor, … | not yet | no 1:1 `SKILL.md` concept; would be a best-effort mapping (e.g. a Codex prompt or a `.cursor/rules` file). Add to `available_agents()` in `installer/core.py` when wanted. | + +## Adding a skill + +Create `skills//` with a `SKILL.md` (frontmatter `name`/`description`) and a `PLAYBOOK.md` +it links to as a sibling; put helper scripts under `scripts/`. The installer **discovers skills from +the folder layout** (any directory here containing a `SKILL.md`), so there is nothing to register — +run `python skills/install.py` and the new skill shows up as a row. If the skill needs packages +beyond Fundus, drop a `requirements.txt` at its root — the installer pip-installs it on install (and +leaves it in place on uninstall). + +Keep the path conventions above: state the skill's own directory in `SKILL.md` via +`${CLAUDE_SKILL_DIR}` (it is substituted nowhere else), and point cross-doc links at repo-root paths +(`/docs/...`). diff --git a/skills/install.py b/skills/install.py new file mode 100644 index 000000000..662483608 --- /dev/null +++ b/skills/install.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Install Fundus agent skills into your coding agent's config directory. + +The skill *sources* live under ``skills/``, tracked in the repo, so the repo itself stays +agent-neutral (we don't commit any agent's config dir). Each skill is a self-contained +folder (``SKILL.md`` + ``PLAYBOOK.md`` + any bundled ``scripts/``); installing copies the +whole folder into your agent's config dir, which is gitignored (project scope) or lives +outside the repo entirely (user scope). Re-install after editing a source to refresh the +installed copy. + +Run it with no arguments for a full-screen status dashboard:: + + python skills/install.py + +The dashboard is a live matrix of *skills* × *scopes* for the selected agent — toggle a +scope on the highlighted skill to (un)install it in place. It needs the ``textual`` package +(it ships in the project's ``dev`` extra: ``pip install -e .[dev]``). + +This file is a thin launcher; the install logic lives in the ``installer`` package next to +it (``installer/core.py`` is stdlib-only, ``installer/tui.py`` is the Textual dashboard). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def main() -> int: + # Allow `python skills/install.py` to find the sibling `installer` package regardless + # of the current working directory. + sys.path.insert(0, str(Path(__file__).resolve().parent)) + try: + from installer.tui import run + except ModuleNotFoundError as exc: + if exc.name in {"textual", "rich"}: + print( + "The skill installer dashboard needs the 'textual' package.\n" + "Install the project's dev extra: pip install -e .[dev]", + file=sys.stderr, + ) + return 1 + raise + run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/installer/__init__.py b/skills/installer/__init__.py new file mode 100644 index 000000000..6ef7a07bf --- /dev/null +++ b/skills/installer/__init__.py @@ -0,0 +1,30 @@ +"""Fundus skill installer. + +:mod:`installer.core` is stdlib-only (discovery + install logic); :mod:`installer.tui` adds +the Textual dashboard. Importing this package pulls in only the core, so it stays usable +without textual installed. +""" + +from __future__ import annotations + +from .core import ( + REPO_ROOT, + SCOPES, + SKILLS_DIR, + Agent, + InstallResult, + Skill, + available_agents, + discover_skills, +) + +__all__ = [ + "REPO_ROOT", + "SCOPES", + "SKILLS_DIR", + "Agent", + "InstallResult", + "Skill", + "available_agents", + "discover_skills", +] diff --git a/skills/installer/core.py b/skills/installer/core.py new file mode 100644 index 000000000..8e1088021 --- /dev/null +++ b/skills/installer/core.py @@ -0,0 +1,219 @@ +"""Installer core: discover skill sources and copy them into an agent's config dir. + +UI-agnostic and **stdlib-only** (no textual/rich), so it can back the dashboard, a +future plain CLI, or tests without dragging in the TUI stack. The two domain objects: + +- :class:`Skill` — a self-contained source folder (``SKILL.md`` + ``PLAYBOOK.md`` + any + bundled ``scripts/``/``requirements.txt``). Discovered from the folder layout, never + registered by hand. +- :class:`Agent` — a coding agent we can install into: it knows where its skills live per + scope, and owns the install/uninstall/is-installed operations for a given skill. + +Installing copies the whole folder (``shutil.copytree``) into the agent's config dir, +which is gitignored (project scope) or lives outside the repo (user scope); re-installing +refreshes the copy in place. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, List, Optional, Tuple + +INSTALLER_DIR = Path(__file__).resolve().parent # skills/installer/ +SKILLS_DIR = INSTALLER_DIR.parent # skills/ +REPO_ROOT = SKILLS_DIR.parent # repo root + +# The scopes a skill can be installed into, in display order. +SCOPES: Tuple[str, ...] = ("project", "user") + + +# --- skills (the sources we install) --- + + +@dataclass(frozen=True) +class Skill: + """A self-contained skill source folder living under ``skills/``.""" + + name: str + source: Path + + @property + def skill_md(self) -> Path: + return self.source / "SKILL.md" + + @property + def requirements(self) -> Optional[Path]: + """The skill's ``requirements.txt`` if it bundles one, else ``None``.""" + req = self.source / "requirements.txt" + return req if req.is_file() else None + + @property + def description(self) -> str: + """The one-line ``description`` from this skill's SKILL.md frontmatter (``""`` if none).""" + return _frontmatter_description(self.skill_md) + + +def discover_skills() -> Dict[str, Skill]: + """A skill is self-identifying: any folder under ``skills/`` with a ``SKILL.md``. + + Nothing to register — drop a folder in and it shows up. (This package has no + ``SKILL.md``, so it is skipped.) + """ + return { + path.name: Skill(path.name, path) + for path in sorted(SKILLS_DIR.iterdir()) + if path.is_dir() and (path / "SKILL.md").is_file() + } + + +# --- agents (where skills get installed) --- + + +@dataclass(frozen=True) +class InstallResult: + """Outcome of an install attempt: whether it ended fully installed, plus log lines.""" + + ok: bool + log: List[str] + + +@dataclass(frozen=True) +class Agent: + """A coding agent we can install skills into.""" + + name: str + skills_root: Callable[[str], Path] # scope -> the agent's skills dir for that scope + + def destination(self, scope: str, skill: Skill) -> Path: + return self.skills_root(scope) / skill.name + + def is_installed(self, scope: str, skill: Skill) -> bool: + return self.destination(scope, skill).exists() + + def install(self, scope: str, skill: Skill) -> InstallResult: + """Copy a skill source into this agent's config dir (refreshing in place), then + install any requirements it bundles. + + The install is **atomic**: if the requirements step fails, the just-copied folder is + rolled back, so ``is_installed`` only ever reports ``True`` for a skill whose deps are + in too. Project scope lands under ``.claude/``, which the repo's committed + ``.gitignore`` covers — an installed copy can never show up in ``git status``. + """ + dest = self.destination(scope, skill) + if dest.exists(): + shutil.rmtree(dest) # refresh in place + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(skill.source, dest) + req_ok, req_log = _install_requirements(skill) + if not req_ok: + shutil.rmtree(dest, ignore_errors=True) # undo the copy — keep the matrix honest + return InstallResult(False, [*req_log, f" rolled back '{skill.name}' ({scope}) — nothing left installed"]) + head = f"installed '{skill.name}' for {self.name} ({scope}) -> {dest / 'SKILL.md'}" + return InstallResult(True, [head, *req_log]) + + def uninstall(self, scope: str, skill: Skill) -> List[str]: + dest = self.destination(scope, skill) + if dest.exists(): + shutil.rmtree(dest) + return [f"removed '{skill.name}' for {self.name} ({scope}) -> {dest}"] + return [f"nothing installed at {dest}"] + + +def _claude_skills_root(scope: str) -> Path: + """Where Claude Code looks for skills, per scope.""" + base = REPO_ROOT if scope == "project" else Path.home() + return base / ".claude" / "skills" + + +def available_agents() -> Dict[str, Agent]: + """Supported agents. + + Only ``claude`` (Claude Code) is supported today. Other agents (Codex, Cursor) have no + 1:1 ``SKILL.md`` concept; add them here when wanted. + """ + return {"claude": Agent("claude", _claude_skills_root)} + + +# --- helpers --- + + +def _install_requirements(skill: Skill) -> Tuple[bool, List[str]]: + """If the skill bundles a ``requirements.txt``, pip-install it into the current interpreter. + + Returns ``(ok, log_lines)``; the caller rolls back the copy when ``ok`` is ``False``. + Runs on install only — uninstall leaves any deps in place, since we don't track which + packages a skill brought in. A skill with no ``requirements.txt`` trivially succeeds. + """ + req = skill.requirements + if req is None: + return True, [] + try: + proc = subprocess.run( + [sys.executable, "-m", "pip", "install", "-r", str(req)], + capture_output=True, + text=True, + ) + except OSError as exc: + return False, [f" ! could not run pip for '{skill.name}' requirements: {exc}"] + if proc.returncode == 0: + return True, [f" installed requirements for '{skill.name}' ({req.name})"] + tail = _pip_error_tail(proc.stdout, proc.stderr) + return False, [ + f" ! pip failed for '{skill.name}' requirements (exit {proc.returncode}):", + *(f" {line}" for line in tail), + ] + + +def _pip_error_tail(stdout: str, stderr: str) -> List[str]: + """Pull the lines that actually explain a pip failure out of its combined output. + + pip prints its "A new release of pip is available / To update, run …" upgrade notice + *last*, so a naive last-N-lines tail shows that noise instead of the real failure. We + drop that notice and, when pip emitted its own ``ERROR:`` lines, surface those; otherwise + we fall back to the last few non-empty lines. + """ + noise = ("A new release of pip", "To update, run") + lines = [ln.rstrip() for ln in f"{stdout}\n{stderr}".splitlines() if ln.strip()] + lines = [ln for ln in lines if not any(n in ln for n in noise)] + errors = [ln for ln in lines if ln.lstrip().startswith("ERROR")] + chosen = errors or lines + return chosen[-3:] if chosen else ["(pip produced no output)"] + + +def _frontmatter_description(skill_md: Path) -> str: + """Pull the one-line ``description`` from a SKILL.md's YAML frontmatter. + + Handles both inline (``description: foo``) and block-scalar (``description: >-``) forms; + returns ``""`` when the file is missing or has no frontmatter description. + """ + try: + text = skill_md.read_text(encoding="utf-8") + except OSError: + return "" + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return "" + try: + end = next(i for i in range(1, len(lines)) if lines[i].strip() == "---") + except StopIteration: + return "" + + parts: List[str] = [] + capturing = False + for line in lines[1:end]: + if not capturing: + if line.startswith("description:"): + capturing = True + rest = line[len("description:") :].strip() + # Skip block-scalar indicators (``>-``, ``|``, …); keep inline text. + if rest and rest[0] not in "|>": + parts.append(rest) + continue + if line and not line[0].isspace(): + break # next top-level frontmatter key + parts.append(line.strip()) + return " ".join(p for p in parts if p) diff --git a/skills/installer/tui.py b/skills/installer/tui.py new file mode 100644 index 000000000..83ad3555d --- /dev/null +++ b/skills/installer/tui.py @@ -0,0 +1,204 @@ +"""Textual dashboard for the skill installer. + +A live matrix of *skills* × *scopes* for the selected agent. Each row is a skill; the +**Project** and **User** columns show whether it's installed (``✓``) or not (``·``). Move +the cursor to a skill and toggle a scope to install it (if absent) or uninstall it (if +present) — the matrix updates in place, no re-running. + +This module owns everything textual/rich; all install logic lives in :mod:`installer.core`. +""" + +from __future__ import annotations + +import textwrap +from typing import Dict, List, Optional, Set, Tuple + +from rich.text import Text +from textual import work +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import DataTable, Footer, Header, RadioButton, RadioSet, RichLog + +from .core import Agent, InstallResult, Skill, available_agents, discover_skills + +_YES = Text("✓", style="bold green", justify="center") +_NO = Text("·", style="dim", justify="center") +# Install failed and was rolled back: a transient marker, replaced by · on the next refresh +# (the rollback makes "not installed" the true steady state, so refresh doesn't lie). +_FAILED = Text("!", style="bold red", justify="center") +_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" # braille "wheel": a busy cell cycles through these frames + + +def _status(installed: bool) -> Text: + return _YES if installed else _NO + + +def _spinner_cell(frame: int) -> Text: + return Text(_SPINNER[frame % len(_SPINNER)], style="bold yellow", justify="center") + + +class InstallerApp(App[None]): + """Live skills × scopes matrix for the selected agent.""" + + TITLE = "Fundus Skill Installer" + + # The matrix is the point of the screen: it gets the flexible space *and* a minimum, + # so a short terminal shrinks the log, never the skills. The agent panel only exists + # when there is an actual choice to make (see compose). + CSS = """ + DataTable { height: 1fr; min-height: 6; margin: 1 1 0 1; border: round $primary; } + #agent { height: auto; border: round $primary; padding: 0 1; margin: 1 1 0 1; } + #agent > RadioButton { width: auto; } + RichLog { height: 5; border: round $panel; padding: 0 1; margin: 1; } + """ + + BINDINGS = [ + Binding("p", "toggle_scope('project')", "Toggle project"), + Binding("u", "toggle_scope('user')", "Toggle user"), + Binding("r", "refresh_matrix", "Refresh"), + Binding("q", "quit", "Quit"), + ] + + skills: Dict[str, Skill] + agents: Dict[str, Agent] + table: DataTable[Text] + output: RichLog + + def __init__(self) -> None: + super().__init__() + self.skills = discover_skills() + self.agents = available_agents() + self._agent_names = sorted(self.agents) + # (skill, scope) cells with an install in flight; the spinner timer animates these. + self._busy: Set[Tuple[str, str]] = set() + self._frame = 0 + + def compose(self) -> ComposeResult: + yield Header() + if len(self._agent_names) > 1: + with RadioSet(id="agent"): + for i, name in enumerate(self._agent_names): + yield RadioButton(name, value=(i == 0)) + self.table = DataTable(cursor_type="row", zebra_stripes=True) + yield self.table + self.output = RichLog(id="log", markup=True, wrap=True) + yield self.output + yield Footer() + + def on_mount(self) -> None: + if len(self._agent_names) > 1: + self.query_one("#agent", RadioSet).border_title = "Agent" + else: + self.sub_title = f"agent: {self._agent_names[0]}" + self.table.border_title = "Skills - ✓ = installed in that scope" + self.table.border_subtitle = "↑/↓ pick a skill · press p / u to (un)install" + self.output.border_title = "Log" + self.table.add_column("Skill", key="skill") + self.table.add_column("Project (p)", key="project", width=12) + self.table.add_column("User (u)", key="user", width=12) + self.table.add_column("Description", key="desc") + self._rebuild() + self.table.focus() + self.output.write("[dim]toggling installs if absent, uninstalls if present · r refresh · q quit[/]") + self.set_interval(0.1, self._tick) # animates the spinner in any busy cell + + # --- state readers --- + + def _agent(self) -> Agent: + if len(self._agent_names) == 1: + return self.agents[self._agent_names[0]] + index = self.query_one("#agent", RadioSet).pressed_index + return self.agents[self._agent_names[index if index >= 0 else 0]] + + def _highlighted_skill(self) -> Optional[Skill]: + names = sorted(self.skills) + row = self.table.cursor_row + return self.skills[names[row]] if 0 <= row < len(names) else None + + # --- rendering --- + + def _rebuild(self) -> None: + """Repopulate every row from disk for the current agent.""" + agent = self._agent() + self.table.clear() + for name in sorted(self.skills): + skill = self.skills[name] + self.table.add_row( + Text(name, style="bold"), + _status(agent.is_installed("project", skill)), + _status(agent.is_installed("user", skill)), + Text(textwrap.shorten(skill.description, width=70, placeholder="…")), + key=name, + ) + + def _refresh_row(self, name: str) -> None: + agent = self._agent() + skill = self.skills[name] + self.table.update_cell(name, "project", _status(agent.is_installed("project", skill))) + self.table.update_cell(name, "user", _status(agent.is_installed("user", skill))) + + def _write_log(self, lines: List[str], style: str = "") -> None: + """Write core-produced log lines as literal Text — styled, and safe against any + ``[...]`` in pip output that a markup-parsing RichLog would otherwise eat.""" + for line in lines: + self.output.write(Text(line, style=style)) + + # --- actions --- + + def on_radio_set_changed(self, event: RadioSet.Changed) -> None: + self._rebuild() + + def action_toggle_scope(self, scope: str) -> None: + skill = self._highlighted_skill() + if skill is None: + return + if self._busy: + return # an install is in flight — ignore toggles until it finishes + agent = self._agent() + if agent.is_installed(scope, skill): + self._write_log(agent.uninstall(scope, skill)) + self._refresh_row(skill.name) + return + # Install may pip-install requirements, which blocks for seconds. Run it in a worker + # thread so the event loop stays free to animate the spinner; mark the cell busy and + # show the first frame at once so there's no dead beat before the timer ticks. + self._busy.add((skill.name, scope)) + self.table.update_cell(skill.name, scope, _spinner_cell(self._frame)) + if skill.requirements is not None: + self.output.write(f"[dim]installing '{skill.name}' ({scope}) — fetching requirements, please wait…[/]") + self._do_install(agent, scope, skill) + + @work(thread=True) + def _do_install(self, agent: Agent, scope: str, skill: Skill) -> None: + try: + result = agent.install(scope, skill) + except Exception as exc: # never let a crashed worker leave the cell spinning forever + result = InstallResult(False, [f" ! install failed for '{skill.name}': {exc}"]) + self.call_from_thread(self._install_done, skill.name, scope, result) + + def _install_done(self, name: str, scope: str, result: InstallResult) -> None: + self._busy.discard((name, scope)) + self._write_log(result.log, "green" if result.ok else "red") # mirrors the matrix ✓ / ! + if result.ok: + self._refresh_row(name) # replaces the spinner with the final ✓ + else: + # Rolled back: flag this attempt with ! now; a refresh will settle it to · (the + # only other cell, the sibling scope, is untouched so we don't disturb it). + self.table.update_cell(name, scope, _FAILED) + + def _tick(self) -> None: + if not self._busy: + return + self._frame += 1 + cell = _spinner_cell(self._frame) + for name, scope in self._busy: + self.table.update_cell(name, scope, cell) + + def action_refresh_matrix(self) -> None: + self._rebuild() + self.output.write("[dim]refreshed from disk[/]") + + +def run() -> None: + """Launch the dashboard.""" + InstallerApp().run()