From 2f855c07c73a0045245ba10b83fa6bdd22014213 Mon Sep 17 00:00:00 2001 From: dinos Date: Tue, 17 Mar 2026 13:04:23 +0100 Subject: [PATCH] feat: add /install-skills command with tag-based skill browsing (#48) * feat: add tag support to skill metadata parser - Add `tags` field to `SkillInfo` dataclass - Extend `_parse_skill_md` to return `SkillInfo` directly (instead of dict), extracting tags from top-level `tags` or `metadata.tags` fallback - Accept `source` as keyword argument in `_parse_skill_md` to avoid post-hoc mutation - Add `_normalize_tags` helper (handles list, comma-string, missing) - Add `list_skills_by_tag()` for filtering installed skills by tag - Add `get_all_tags()` returning tags sorted by count then alphabetically - Add `fetch_remote_skill_index()` with shallow-clone and 10-min cache - Add 12 new tests covering tag parsing, filtering, and remote index * feat: add /install-skills command and browse action to skill_manager tool CLI: - Add `/install-skills` slash command with interactive tag picker and skill checkbox (questionary-based, for CLI mode) - Accepts optional tag argument for pre-filtering: `/install-skills core` - Update `/skills` listing to show tags per skill - Register command in interactive.py dispatch LangChain tool: - Add `browse` action to `skill_manager` tool with optional `tag` filter - Update `list` and `info` actions to include tags in output * feat: add interactive skill browser widget for TUI New widget (skill_browser.py): - Two-phase keyboard-driven widget mounted inline in chat - Phase 1: tag picker (arrow keys + Enter, Esc to cancel) - Phase 2: skill checkbox (Space to toggle, Enter to install, Esc back) - Width-aware description truncation with ellipsis - Installed skills shown as non-toggleable with checkmark TUI integration (tui_interactive.py): - Register /install-skills command with async widget flow - Echo executed commands in cyan before output - Add SkillBrowserWidget keyboard delegation (up/down/esc) - Refocus prompt input after any widget dismissal (also fixes pre-existing /resume and /delete focus bug) - Show tags as bulleted newlines in /skills table - Dynamic autocomplete padding based on longest item --- EvoScientist/cli/interactive.py | 13 +- EvoScientist/cli/skills_cmd.py | 208 ++++++++++++- EvoScientist/cli/tui_interactive.py | 133 +++++++- EvoScientist/cli/widgets/skill_browser.py | 353 ++++++++++++++++++++++ EvoScientist/tools/skill_manager.py | 52 +++- EvoScientist/tools/skills_manager.py | 196 +++++++++--- tests/test_skills_manager.py | 268 +++++++++++++++- 7 files changed, 1153 insertions(+), 70 deletions(-) create mode 100644 EvoScientist/cli/widgets/skill_browser.py diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index c6e31e1..bea650e 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -46,7 +46,12 @@ from .channel import ( ) import EvoScientist.cli.channel as _ch_mod from .mcp_ui import _cmd_mcp -from .skills_cmd import _cmd_list_skills, _cmd_install_skill, _cmd_uninstall_skill +from .skills_cmd import ( + _cmd_list_skills, + _cmd_install_skill, + _cmd_uninstall_skill, + _cmd_install_skills, +) _channel_logger = logging.getLogger(__name__) @@ -116,6 +121,7 @@ _SLASH_COMMANDS = [ ("/skills", "List installed skills"), ("/install-skill", "Add a skill from path or GitHub"), ("/uninstall-skill", "Remove an installed skill"), + ("/install-skills", "Browse and install skills (optional: /install-skills )"), ("/mcp", "Manage MCP servers"), ("/channel", "Configure messaging channels"), ("/compact", "Compact conversation to free context"), @@ -759,6 +765,11 @@ def cmd_interactive( _cmd_uninstall_skill(name) continue + if user_input.lower().startswith("/install-skills"): + browse_args = user_input[len("/install-skills") :].strip() + _cmd_install_skills(browse_args) + continue + if user_input.lower().startswith("/mcp"): _cmd_mcp(user_input[len("/mcp") :]) continue diff --git a/EvoScientist/cli/skills_cmd.py b/EvoScientist/cli/skills_cmd.py index 3f30b74..a2d4b50 100644 --- a/EvoScientist/cli/skills_cmd.py +++ b/EvoScientist/cli/skills_cmd.py @@ -1,4 +1,6 @@ -"""Slash commands for skill management: /skills, /install-skill, /uninstall-skill.""" +"""Slash commands for skill management: /skills, /install-skill, /uninstall-skill, /install-skills.""" + +from pathlib import Path from ..stream.display import console from .agent import _shorten_path @@ -26,7 +28,10 @@ def _cmd_list_skills() -> None: if user_skills: console.print(f"[bold]User Skills[/bold] ({len(user_skills)}):") for skill in user_skills: - console.print(f" [green]{skill.name}[/green] - {skill.description}") + tags_str = f" [dim]({', '.join(skill.tags)})[/dim]" if skill.tags else "" + console.print( + f" [green]{skill.name}[/green] - {skill.description}{tags_str}" + ) if user_skills and system_skills: console.print() @@ -34,7 +39,10 @@ def _cmd_list_skills() -> None: if system_skills: console.print(f"[bold]Built-in Skills[/bold] ({len(system_skills)}):") for skill in system_skills: - console.print(f" [cyan]{skill.name}[/cyan] - {skill.description}") + tags_str = f" [dim]({', '.join(skill.tags)})[/dim]" if skill.tags else "" + console.print( + f" [cyan]{skill.name}[/cyan] - {skill.description}{tags_str}" + ) console.print( f"\n[dim]User skills folder:[/dim] [green]{_shorten_path(str(USER_SKILLS_DIR))}[/green]" @@ -106,3 +114,197 @@ def _cmd_uninstall_skill(name: str) -> None: else: console.print(f"[red]Failed:[/red] {result['error']}") console.print() + + +def _cmd_install_skills(args: str = "") -> None: + """Browse and install skills from the EvoSkills repository. + + Args: + args: Optional tag name to pre-filter (e.g. "core"). + """ + from collections import Counter + + import questionary + from prompt_toolkit.styles import Style as PtStyle + from questionary import Choice + + from ..paths import USER_SKILLS_DIR + from ..tools.skills_manager import fetch_remote_skill_index, install_skill + + _PICKER_STYLE = PtStyle.from_dict( + { + "questionmark": "#888888", + "question": "", + "pointer": "bold", + "highlighted": "bold", + "text": "#888888", + "answer": "bold", + } + ) + + # Installed-item indicator style for disabled checkbox choices. + _INSTALLED_INDICATOR = ("fg:#4caf50", "✓ ") + + def _checkbox_ask(choices, message: str, **kwargs): + """questionary.checkbox that renders disabled items with checkmark.""" + from questionary.prompts.common import InquirerControl + + original = InquirerControl._get_choice_tokens + + def _patched(self): + tokens = original(self) + return [ + _INSTALLED_INDICATOR + if cls == "class:disabled" and text == "- " + else (cls, text) + for cls, text in tokens + ] + + InquirerControl._get_choice_tokens = _patched + try: + return questionary.checkbox( + message, + choices=choices, + style=_PICKER_STYLE, + qmark="❯", + **kwargs, + ).ask() + finally: + InquirerControl._get_choice_tokens = original + + # Step 1: Fetch remote index + console.print("[dim]Fetching skill index...[/dim]") + try: + index = fetch_remote_skill_index() + except Exception as e: + console.print(f"[red]Failed to fetch skill index: {e}[/red]") + console.print( + "[dim]Try installing directly: /install-skill EvoScientist/EvoSkills@skills[/dim]" + ) + console.print() + return + + if not index: + console.print("[yellow]No skills found in the repository.[/yellow]") + console.print() + return + + # Detect already-installed skills + skills_dir = Path(USER_SKILLS_DIR) + installed_names: set[str] = set() + if skills_dir.exists(): + installed_names = {e.name for e in skills_dir.iterdir() if e.is_dir()} + + pre_filter_tag = args.strip().lower() if args else "" + + # Step 2: Tag filter (skip if pre-filtered via args) + if pre_filter_tag: + filtered = [ + s for s in index if pre_filter_tag in [t.lower() for t in s.get("tags", [])] + ] + if not filtered: + console.print(f"[yellow]No skills found with tag: {args.strip()}[/yellow]") + # Show available tags + tag_counter: Counter[str] = Counter() + for s in index: + for t in s.get("tags", []): + tag_counter[t.lower()] += 1 + if tag_counter: + sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0])) + tags_str = ", ".join(f"{tag} ({count})" for tag, count in sorted_tags) + console.print(f"[dim]Available tags: {tags_str}[/dim]") + console.print() + return + else: + # Build tag choices for interactive picker + tag_counter = Counter() + for s in index: + for t in s.get("tags", []): + tag_counter[t.lower()] += 1 + + sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0])) + tag_choices = [Choice(title=f"All skills ({len(index)})", value="__all__")] + for tag, count in sorted_tags: + tag_choices.append(Choice(title=f"{tag} ({count})", value=tag)) + + selected_tag = questionary.select( + "Filter by tag:", + choices=tag_choices, + style=_PICKER_STYLE, + qmark="❯", + ).ask() + + if selected_tag is None: + console.print() + return + + if selected_tag == "__all__": + filtered = index + else: + filtered = [ + s + for s in index + if selected_tag in [t.lower() for t in s.get("tags", [])] + ] + + # Step 3: Skill selection checkbox + all_installed = all(s["name"] in installed_names for s in filtered) + if all_installed: + console.print( + "[green]All skills in this category are already installed.[/green]" + ) + console.print() + return + + choices = [] + for s in filtered: + if s["name"] in installed_names: + choices.append( + Choice( + title=[ + ("", f"{s['name']} — {s['description'][:80]}"), + ("class:instruction", " (installed)"), + ], + value=s["install_source"], + disabled=True, + ) + ) + else: + choices.append( + Choice( + title=f"{s['name']} — {s['description'][:80]}", + value=s["install_source"], + ) + ) + + selected = _checkbox_ask(choices, "Select skills to install:") + + if selected is None: + console.print() + return + + if not selected: + console.print("[dim]No skills selected.[/dim]") + console.print() + return + + # Step 4: Install selected skills + installed_count = 0 + for source in selected: + result = install_skill(source) + if result.get("batch"): + for item in result.get("installed", []): + console.print(f"[green]Installed:[/green] {item['name']}") + installed_count += 1 + for item in result.get("failed", []): + console.print(f"[red]Failed:[/red] {item['name']} — {item['error']}") + elif result.get("success"): + console.print(f"[green]Installed:[/green] {result['name']}") + installed_count += 1 + else: + console.print(f"[red]Failed:[/red] {result.get('error', 'unknown')}") + + if installed_count: + console.print(f"\n[green]{installed_count} skill(s) installed.[/green]") + console.print("[dim]Reload with /new to apply.[/dim]") + console.print() diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index f95d4b5..3e05986 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -57,6 +57,7 @@ _TUI_SLASH_COMMANDS = [ ("/skills", "List installed skills"), ("/install-skill", "Add a skill from path or GitHub"), ("/uninstall-skill", "Remove an installed skill"), + ("/install-skills", "Browse and install skills (optional: /install-skills )"), ("/mcp", "Manage MCP servers"), ("/channel", "Configure messaging channels"), ("/compact", "Compact conversation to free context"), @@ -321,6 +322,7 @@ def run_textual_interactive( self._approval_future: asyncio.Future | None = None self._ask_user_future: asyncio.Future | None = None self._picker_future: asyncio.Future | None = None + self._browser_future: asyncio.Future | None = None self._history_suggester = HistorySuggester(get_config_dir() / "history") # ── Layout ───────────────────────────────────────────── @@ -476,6 +478,7 @@ def run_textual_interactive( picker_widget.remove() except Exception: pass + self.query_one("#prompt", Input).focus() def on_thread_picker_widget_picked(self, event) -> None: # type: ignore[override] """Handle ThreadPickerWidget.Picked message.""" @@ -487,6 +490,34 @@ def run_textual_interactive( if self._picker_future and not self._picker_future.done(): self._picker_future.set_result(None) + async def _wait_for_skill_browse(self, browser_widget) -> list[str] | None: + """Wait for user to complete skill browsing. + + Returns list of install sources, or None on cancel/timeout. + """ + self._browser_future = asyncio.get_event_loop().create_future() + try: + return await asyncio.wait_for(self._browser_future, timeout=300) + except (asyncio.TimeoutError, asyncio.CancelledError): + return None + finally: + self._browser_future = None + try: + browser_widget.remove() + except Exception: + pass + self.query_one("#prompt", Input).focus() + + def on_skill_browser_widget_confirmed(self, event) -> None: # type: ignore[override] + """Handle SkillBrowserWidget.Confirmed message.""" + if self._browser_future and not self._browser_future.done(): + self._browser_future.set_result(event.install_sources) + + def on_skill_browser_widget_cancelled(self, event) -> None: # type: ignore[override] + """Handle SkillBrowserWidget.Cancelled message.""" + if self._browser_future and not self._browser_future.done(): + self._browser_future.set_result(None) + # ── Streaming core ───────────────────────────────────── async def _stream_with_widgets( @@ -1398,10 +1429,11 @@ def run_textual_interactive( # Force-resolve the future self._ask_user_future.set_result({"type": "cancelled"}) return - # Delegate to ApprovalWidget or ThreadPickerWidget if focused + # Delegate to ApprovalWidget, ThreadPickerWidget, or SkillBrowserWidget if focused focused = self.focused if focused is not None: from .widgets.approval_widget import ApprovalWidget + from .widgets.skill_browser import SkillBrowserWidget from .widgets.thread_selector import ThreadPickerWidget if isinstance(focused, ApprovalWidget): @@ -1410,17 +1442,21 @@ def run_textual_interactive( if isinstance(focused, ThreadPickerWidget): focused.action_cancel() return + if isinstance(focused, SkillBrowserWidget): + focused.action_cancel() + return if self._queued_messages: self._queued_messages.pop() self._render_queue_indicator() def action_edit_queued(self) -> None: """Pop the last queued message back into input for editing.""" - # Skip if an ApprovalWidget, AskUserWidget, or ThreadPickerWidget has focus + # Skip if an ApprovalWidget, AskUserWidget, ThreadPickerWidget, or SkillBrowserWidget has focus focused = self.focused if focused is not None: from .widgets.approval_widget import ApprovalWidget from .widgets.ask_user_widget import AskUserWidget + from .widgets.skill_browser import SkillBrowserWidget from .widgets.thread_selector import ThreadPickerWidget if isinstance(focused, ApprovalWidget): @@ -1432,6 +1468,9 @@ def run_textual_interactive( if isinstance(focused, ThreadPickerWidget): focused.action_move_up() return + if isinstance(focused, SkillBrowserWidget): + focused.action_move_up() + return if self._queued_messages: last = self._queued_messages.pop() prompt = self.query_one("#prompt", Input) @@ -1441,11 +1480,12 @@ def run_textual_interactive( self._render_queue_indicator() def action_down_delegate(self) -> None: - """Delegate down key to focused ApprovalWidget, AskUserWidget, or ThreadPickerWidget.""" + """Delegate down key to focused interactive widget.""" focused = self.focused if focused is not None: from .widgets.approval_widget import ApprovalWidget from .widgets.ask_user_widget import AskUserWidget + from .widgets.skill_browser import SkillBrowserWidget from .widgets.thread_selector import ThreadPickerWidget if isinstance(focused, ApprovalWidget): @@ -1457,6 +1497,9 @@ def run_textual_interactive( if isinstance(focused, ThreadPickerWidget): focused.action_move_down() return + if isinstance(focused, SkillBrowserWidget): + focused.action_move_down() + return def on_key(self, event: Any) -> None: comp_widget = self.query_one("#completions", Static) @@ -1513,6 +1556,9 @@ def run_textual_interactive( cmd = cmd.lower() arg = arg.strip() + # Echo the command so the user sees what they ran + self._append_system(command.strip(), style="cyan") + if cmd in ("/exit", "/quit", "/q"): self.action_request_quit() return @@ -1597,6 +1643,10 @@ def run_textual_interactive( self._cmd_uninstall_skill(arg) return + if cmd == "/install-skills": + await self._cmd_install_skills(arg) + return + if cmd == "/mcp": self._cmd_mcp(arg) return @@ -1830,8 +1880,10 @@ def run_textual_interactive( ) table.add_column("Name", style="green") table.add_column("Description", style="dim") + table.add_column("Tags", style="dim") for s in user_skills: - table.add_row(s.name, s.description) + tags = "\n".join(f"· {t}" for t in s.tags[:4]) if s.tags else "" + table.add_row(s.name, s.description, tags) self._mount_renderable(table) if system_skills: @@ -1840,8 +1892,10 @@ def run_textual_interactive( ) table.add_column("Name", style="cyan") table.add_column("Description", style="dim") + table.add_column("Tags", style="dim") for s in system_skills: - table.add_row(s.name, s.description) + tags = "\n".join(f"· {t}" for t in s.tags[:4]) if s.tags else "" + table.add_row(s.name, s.description, tags) self._mount_renderable(table) self._append_system( @@ -1882,6 +1936,75 @@ def run_textual_interactive( else: self._append_system(f"Failed: {result['error']}", style="red") + async def _cmd_install_skills(self, args: str) -> None: + from pathlib import Path as _Path + + from ..paths import USER_SKILLS_DIR + from ..tools.skills_manager import fetch_remote_skill_index, install_skill + + self._append_system("Fetching skill index...", style="dim") + + try: + index = fetch_remote_skill_index() + except Exception as e: + self._append_system(f"Failed to fetch skill index: {e}", style="red") + self._append_system( + "Try: /install-skill EvoScientist/EvoSkills@skills", style="dim" + ) + return + + if not index: + self._append_system("No skills found.", style="yellow") + return + + # Detect installed skills + skills_dir = _Path(USER_SKILLS_DIR) + installed_names: set[str] = set() + if skills_dir.exists(): + installed_names = {e.name for e in skills_dir.iterdir() if e.is_dir()} + + # Mount interactive browser widget + from .widgets.skill_browser import SkillBrowserWidget + + container = self.query_one("#chat", VerticalScroll) + browser = SkillBrowserWidget( + index, + installed_names, + pre_filter_tag=args.strip(), + ) + await container.mount(browser) + container.scroll_end(animate=False) + browser.focus() + + # Wait for user interaction + selected_sources = await self._wait_for_skill_browse(browser) + + if not selected_sources: + self._append_system("Browse cancelled.", style="dim") + return + + # Install selected skills + installed_count = 0 + for source in selected_sources: + result = install_skill(source) + if result.get("batch"): + for item in result.get("installed", []): + self._append_system(f"Installed: {item['name']}", style="green") + installed_count += 1 + elif result.get("success"): + self._append_system(f"Installed: {result['name']}", style="green") + installed_count += 1 + else: + self._append_system( + f"Failed: {result.get('error', 'unknown')}", style="red" + ) + + if installed_count: + self._append_system( + f"{installed_count} skill(s) installed. Reload with /new to apply.", + style="green", + ) + def _cmd_uninstall_skill(self, name: str) -> None: from ..tools.skills_manager import uninstall_skill diff --git a/EvoScientist/cli/widgets/skill_browser.py b/EvoScientist/cli/widgets/skill_browser.py new file mode 100644 index 0000000..9f20978 --- /dev/null +++ b/EvoScientist/cli/widgets/skill_browser.py @@ -0,0 +1,353 @@ +"""Inline skill browser widget for /install-skills in TUI. + +Two-phase keyboard-driven widget: + Phase 1 — tag picker (arrow keys + Enter to select, or Esc for all) + Phase 2 — skill checkbox (arrow keys to navigate, Space to toggle, Enter to confirm) + +Posts ``SkillBrowserWidget.Confirmed`` with selected install sources, +or ``SkillBrowserWidget.Cancelled`` on Esc. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from rich.text import Text + +from textual.binding import Binding, BindingType +from textual.containers import Container +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Static + +if TYPE_CHECKING: + from textual import events + from textual.app import ComposeResult + + +class SkillBrowserWidget(Widget): + """Inline skill browser — mounts in chat, keyboard-driven. + + Phase 1: Tag picker (select a tag filter or "All"). + Phase 2: Skill checkbox (toggle skills, confirm to install). + """ + + can_focus = True + can_focus_children = False + + DEFAULT_CSS = """ + SkillBrowserWidget { + height: auto; + max-height: 30; + margin: 1 0; + padding: 0 1; + background: $surface; + border: solid $primary; + } + SkillBrowserWidget .browser-title { + height: 1; + text-style: bold; + color: $primary; + } + SkillBrowserWidget .browser-rows { + height: auto; + max-height: 20; + overflow-y: auto; + } + SkillBrowserWidget .browser-row { + height: 1; + padding: 0 1; + } + SkillBrowserWidget .browser-row-selected { + background: $primary; + text-style: bold; + } + SkillBrowserWidget .browser-help { + height: 1; + color: $text-muted; + text-style: italic; + } + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", "Up", show=False), + Binding("k", "move_up", "Up", show=False), + Binding("down", "move_down", "Down", show=False), + Binding("j", "move_down", "Down", show=False), + Binding("enter", "confirm", "Confirm", show=False), + Binding("space", "toggle", "Toggle", show=False), + Binding("escape", "cancel", "Cancel", show=False), + ] + + class Confirmed(Message): + """Posted when user confirms skill selection.""" + + def __init__(self, install_sources: list[str]) -> None: + super().__init__() + self.install_sources = install_sources + + class Cancelled(Message): + """Posted when user cancels.""" + + def __init__( + self, + index: list[dict], + installed_names: set[str], + *, + pre_filter_tag: str = "", + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._index = index + self._installed_names = installed_names + self._pre_filter_tag = pre_filter_tag.lower() + self._selected = 0 + self._row_widgets: list[Static] = [] + self._title_widget: Static | None = None + self._help_widget: Static | None = None + + # Phase 1: tag picker + # Phase 2: skill checkbox + self._phase: int = 1 + self._tag_items: list[tuple[str, int]] = [] # (tag, count) + self._skill_items: list[dict] = [] # filtered skills + self._checked: set[int] = set() # indices of checked skills + + # Build tag list (sorted by count desc, then alphabetically) + from collections import Counter + + tag_counter: Counter[str] = Counter() + for s in self._index: + for t in s.get("tags", []): + tag_counter[t.lower()] += 1 + sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0])) + self._tag_items = [("all", len(self._index))] + sorted_tags + + # If pre-filtered, skip to phase 2 + if self._pre_filter_tag: + self._skill_items = [ + s + for s in self._index + if self._pre_filter_tag in [t.lower() for t in s.get("tags", [])] + ] + if self._skill_items: + self._phase = 2 + else: + # No matches — show tag picker anyway + self._pre_filter_tag = "" + + def compose(self) -> ComposeResult: + self._title_widget = Static("", classes="browser-title") + yield self._title_widget + with Container(classes="browser-rows"): + # Pre-allocate enough rows for the larger of tag list or skill list + max_rows = max(len(self._tag_items), len(self._index)) + for _ in range(max_rows): + widget = Static("", classes="browser-row") + self._row_widgets.append(widget) + yield widget + self._help_widget = Static("", classes="browser-help") + yield self._help_widget + + def on_mount(self) -> None: + # Defer rendering until after layout so self.size is populated + self.call_after_refresh(self._update_display) + self.call_later(self.focus) + + def _update_display(self) -> None: + if self._phase == 1: + self._render_tag_picker() + else: + self._render_skill_checkbox() + + def _render_tag_picker(self) -> None: + if self._title_widget: + self._title_widget.update("Filter by tag:") + if self._help_widget: + self._help_widget.update("↑/↓ navigate · Enter select · Esc cancel") + + for i, widget in enumerate(self._row_widgets): + if i < len(self._tag_items): + tag, count = self._tag_items[i] + is_selected = i == self._selected + text = Text() + cursor = "▸ " if is_selected else " " + text.append(cursor, style="bold cyan" if is_selected else "dim") + label = f"{tag} ({count})" + text.append(label, style="bold" if is_selected else "") + widget.update(text) + widget.display = True + widget.remove_class("browser-row-selected") + if is_selected: + widget.add_class("browser-row-selected") + widget.scroll_visible() + else: + widget.update("") + widget.display = False + + def _row_content_width(self) -> int: + """Get the usable character width for a row's text content. + + Accounts for widget border, widget padding, and row padding. + Falls back to terminal width if the widget hasn't been laid out yet. + """ + try: + w = self.size.width + if w > 0: + # border (2) + widget padding-left/right (2) + row padding-left/right (2) + return w - 6 + except Exception: + pass + # Fallback: use terminal width minus reasonable chrome + try: + return self.app.size.width - 10 + except Exception: + return 100 + + def _truncate(self, desc: str, name: str, *, suffix: str = "") -> str: + """Truncate a description to fit the row, adding ellipsis if needed.""" + # cursor(2) + indicator(2) + name + " — "(3) + suffix + overhead = 2 + 2 + len(name) + 3 + len(suffix) + max_len = max(20, self._row_content_width() - overhead) + if len(desc) <= max_len: + return desc + return desc[: max_len - 1] + "…" + + def _render_skill_checkbox(self) -> None: + n_checked = len( + [ + i + for i in self._checked + if self._skill_items[i]["name"] not in self._installed_names + ] + ) + if self._title_widget: + self._title_widget.update( + f"Select skills to install ({n_checked} selected):" + ) + if self._help_widget: + self._help_widget.update( + "↑/↓ navigate · Space toggle · Enter install · Esc cancel" + ) + + for i, widget in enumerate(self._row_widgets): + if i < len(self._skill_items): + skill = self._skill_items[i] + is_selected = i == self._selected + is_installed = skill["name"] in self._installed_names + is_checked = i in self._checked + + text = Text() + cursor = "▸ " if is_selected else " " + text.append(cursor, style="bold cyan" if is_selected else "dim") + + if is_installed: + suffix = " (installed)" + desc = self._truncate( + desc=skill["description"], + name=skill["name"], + suffix=suffix, + ) + text.append("✓ ", style="green") + text.append(skill["name"], style="green dim") + text.append(f" — {desc}", style="dim") + text.append(suffix, style="dim italic") + elif is_checked: + desc = self._truncate(skill["description"], skill["name"]) + text.append("● ", style="green bold") + text.append(skill["name"], style="bold") + text.append(f" — {desc}", style="") + else: + desc = self._truncate(skill["description"], skill["name"]) + text.append("○ ", style="dim") + text.append(skill["name"], style="bold" if is_selected else "") + text.append(f" — {desc}", style="dim") + + widget.update(text) + widget.display = True + widget.remove_class("browser-row-selected") + if is_selected: + widget.add_class("browser-row-selected") + widget.scroll_visible() + else: + widget.update("") + widget.display = False + + def _current_items_count(self) -> int: + if self._phase == 1: + return len(self._tag_items) + return len(self._skill_items) + + def action_move_up(self) -> None: + n = self._current_items_count() + if not n: + return + self._selected = (self._selected - 1) % n + self._update_display() + + def action_move_down(self) -> None: + n = self._current_items_count() + if not n: + return + self._selected = (self._selected + 1) % n + self._update_display() + + def action_toggle(self) -> None: + """Toggle skill selection (phase 2 only).""" + if self._phase != 2: + return + if not self._skill_items: + return + skill = self._skill_items[self._selected] + if skill["name"] in self._installed_names: + return # Can't toggle installed skills + if self._selected in self._checked: + self._checked.discard(self._selected) + else: + self._checked.add(self._selected) + # Move down after toggle for quick multi-select + n = len(self._skill_items) + if n > 1: + self._selected = (self._selected + 1) % n + self._update_display() + + def action_confirm(self) -> None: + if self._phase == 1: + # Transition to phase 2 + if not self._tag_items: + return + tag, _ = self._tag_items[self._selected] + if tag == "all": + self._skill_items = list(self._index) + else: + self._skill_items = [ + s + for s in self._index + if tag in [t.lower() for t in s.get("tags", [])] + ] + self._phase = 2 + self._selected = 0 + self._checked = set() + self._update_display() + else: + # Confirm selection + sources = [ + self._skill_items[i]["install_source"] + for i in sorted(self._checked) + if self._skill_items[i]["name"] not in self._installed_names + ] + self.post_message(self.Confirmed(sources)) + + def action_cancel(self) -> None: + if self._phase == 2 and not self._pre_filter_tag: + # Go back to tag picker + self._phase = 1 + self._selected = 0 + self._checked = set() + self._update_display() + else: + self.post_message(self.Cancelled()) + + def on_blur(self, event: events.Blur) -> None: + """Re-focus to keep focus trapped until decision is made.""" + self.call_after_refresh(self.focus) diff --git a/EvoScientist/tools/skill_manager.py b/EvoScientist/tools/skill_manager.py index 9897c64..824c8a7 100644 --- a/EvoScientist/tools/skill_manager.py +++ b/EvoScientist/tools/skill_manager.py @@ -7,12 +7,13 @@ from langchain_core.tools import tool @tool(parse_docstring=True) def skill_manager( - action: Literal["install", "list", "uninstall", "info"], + action: Literal["install", "list", "uninstall", "info", "browse"], source: str = "", name: str = "", + tag: str = "", include_system: bool = False, ) -> str: - """Manage user-installable skills: install from GitHub or local path, list available skills, get details, or uninstall. + """Manage user-installable skills: install from GitHub or local path, list available skills, browse remote skills, get details, or uninstall. Actions and required parameters: @@ -28,17 +29,23 @@ def skill_manager( Set include_system=True to also show built-in system skills. Built-in skills evolve over time, so use action="list" to see the current set. + action="browse" (optional tag): + Browse available skills from the EvoSkills repository (EvoScientist/EvoSkills). + Set tag to filter by category (e.g. tag="core", tag="writing", tag="experiments"). + Returns skill names, descriptions, tags, and install sources you can pass to action="install". + action="info" (requires name): - Get details (description, source, path) about a specific skill by name. + Get details (description, source, path, tags) about a specific skill by name. Searches both user and system skills. action="uninstall" (requires name): Remove a user-installed skill by name. System skills cannot be uninstalled. Args: - action: The operation to perform — "install", "list", "info", or "uninstall" + action: The operation to perform — "install", "list", "browse", "info", or "uninstall" source: Required for install — GitHub shorthand, GitHub URL, or local directory path name: Required for info and uninstall — the skill name (for example, one returned by action="list") + tag: Optional for browse — filter by tag (e.g. "core", "writing", "experiments", "research") include_system: Only for list — set True to include built-in system skills in the output Returns: @@ -49,6 +56,7 @@ def skill_manager( list_skills, uninstall_skill, get_skill_info, + fetch_remote_skill_index, ) if action == "install": @@ -81,13 +89,38 @@ def skill_manager( if user_skills: lines.append(f"User Skills ({len(user_skills)}):") for skill in user_skills: - lines.append(f" - {skill.name}: {skill.description}") + tags_str = f" [{', '.join(skill.tags)}]" if skill.tags else "" + lines.append(f" - {skill.name}: {skill.description}{tags_str}") if system_skills: if lines: lines.append("") lines.append(f"System Skills ({len(system_skills)}):") for skill in system_skills: - lines.append(f" - {skill.name}: {skill.description}") + tags_str = f" [{', '.join(skill.tags)}]" if skill.tags else "" + lines.append(f" - {skill.name}: {skill.description}{tags_str}") + return "\n".join(lines) + + elif action == "browse": + try: + index = fetch_remote_skill_index() + except Exception as e: + return f"Failed to fetch skill index: {e}" + if tag: + tag_lower = tag.lower() + index = [ + s for s in index if tag_lower in [t.lower() for t in s.get("tags", [])] + ] + if not index: + return f"No skills found{' with tag: ' + tag if tag else ''}." + lines = [f"Available Skills ({len(index)}):"] + for s in index: + tags_str = " · ".join(s.get("tags", [])) + lines.append(f" - {s['name']}: {s['description']}") + if tags_str: + lines.append(f" Tags: {tags_str}") + lines.append( + f" Install: skill_manager(action='install', source='{s['install_source']}')" + ) return "\n".join(lines) elif action == "uninstall": @@ -114,14 +147,13 @@ def skill_manager( f"Skill not found: {name}. " f"Use action='list' with include_system=True to see all available skills." ) + tags_str = f"\nTags: {', '.join(info.tags)}" if info.tags else "" return ( f"Name: {info.name}\n" f"Description: {info.description}\n" f"Source: {info.source}\n" - f"Path: {info.path}" + f"Path: {info.path}{tags_str}" ) else: - return ( - f"Unknown action: {action}. Use 'install', 'list', 'uninstall', or 'info'." - ) + return f"Unknown action: {action}. Use 'install', 'list', 'browse', 'uninstall', or 'info'." diff --git a/EvoScientist/tools/skills_manager.py b/EvoScientist/tools/skills_manager.py index a31b25d..e4708c2 100644 --- a/EvoScientist/tools/skills_manager.py +++ b/EvoScientist/tools/skills_manager.py @@ -32,7 +32,8 @@ import re import shutil import subprocess import tempfile -from dataclasses import dataclass +import time +from dataclasses import dataclass, field from pathlib import Path import yaml @@ -48,49 +49,74 @@ class SkillInfo: description: str path: Path source: str # "user" or "system" + tags: list[str] = field(default_factory=list) -def _parse_skill_md(skill_md_path: Path) -> dict[str, str]: - """Parse SKILL.md frontmatter to extract name and description. +def _normalize_tags(raw: object) -> list[str]: + """Normalize a tags value to a list of strings.""" + if isinstance(raw, list): + return [str(t).strip() for t in raw if str(t).strip()] + if isinstance(raw, str): + return [t.strip() for t in raw.split(",") if t.strip()] + return [] + + +def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo: + """Parse SKILL.md frontmatter to extract name, description, and tags. SKILL.md format: --- name: skill-name description: A brief description... + tags: [tag1, tag2] + metadata: + tags: [tag1, tag2] # fallback location --- # Skill Title ... + Args: + skill_md_path: Path to the SKILL.md file. + source: Origin label (e.g. "user", "system"). + Returns: - Dictionary with 'name' and 'description' keys. + SkillInfo with path set to the skill's parent directory. """ + parent = skill_md_path.parent content = skill_md_path.read_text(encoding="utf-8") + def _info(name: str, description: str, tags: list[str] | None = None) -> SkillInfo: + return SkillInfo( + name=name, + description=description, + path=parent, + source=source, + tags=tags or [], + ) + # Extract YAML frontmatter frontmatter_match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL) if not frontmatter_match: # No frontmatter, use directory name - return { - "name": skill_md_path.parent.name, - "description": "(no description)", - } + return _info(parent.name, "(no description)") try: frontmatter = yaml.safe_load(frontmatter_match.group(1)) if not isinstance(frontmatter, dict): - return { - "name": skill_md_path.parent.name, - "description": "(empty frontmatter)", - } - return { - "name": frontmatter.get("name", skill_md_path.parent.name), - "description": frontmatter.get("description", "(no description)"), - } + return _info(parent.name, "(empty frontmatter)") + # Tags: check top-level first, fall back to metadata.tags + tags = _normalize_tags(frontmatter.get("tags")) + if not tags: + metadata = frontmatter.get("metadata") + if isinstance(metadata, dict): + tags = _normalize_tags(metadata.get("tags")) + return _info( + frontmatter.get("name", parent.name), + frontmatter.get("description", "(no description)"), + tags, + ) except yaml.YAMLError: - return { - "name": skill_md_path.parent.name, - "description": "(invalid frontmatter)", - } + return _info(parent.name, "(invalid frontmatter)") def _parse_github_url(url: str) -> tuple[str, str | None, str | None]: @@ -287,18 +313,18 @@ def _install_from_local(source: str, dest_dir: str) -> dict: def _install_single_local(source_path: Path, dest_dir: str, *, ignore_fn=None) -> dict: """Install one skill directory into *dest_dir*.""" skill_info = _parse_skill_md(source_path / "SKILL.md") - skill_name = _sanitize_name(skill_info["name"]) + skill_name = _sanitize_name(skill_info.name) if not skill_name: return { "success": False, - "error": f"Invalid skill name in SKILL.md: {skill_info['name']!r}", + "error": f"Invalid skill name in SKILL.md: {skill_info.name!r}", } target_path = (Path(dest_dir) / skill_name).resolve() if not str(target_path).startswith(str(Path(dest_dir).resolve())): return { "success": False, - "error": f"Skill name escapes destination: {skill_info['name']!r}", + "error": f"Skill name escapes destination: {skill_info.name!r}", } if target_path.exists(): @@ -310,7 +336,7 @@ def _install_single_local(source_path: Path, dest_dir: str, *, ignore_fn=None) - "success": True, "name": skill_name, "path": str(target_path), - "description": skill_info["description"], + "description": skill_info.description, } @@ -413,16 +439,7 @@ def list_skills(include_system: bool = False) -> list[SkillInfo]: if user_dir.exists(): for entry in sorted(user_dir.iterdir()): if entry.is_dir() and _validate_skill_dir(entry): - skill_md = entry / "SKILL.md" - info = _parse_skill_md(skill_md) - skills.append( - SkillInfo( - name=info["name"], - description=info["description"], - path=entry, - source="user", - ) - ) + skills.append(_parse_skill_md(entry / "SKILL.md", source="user")) # System skills (optional) if include_system: @@ -435,16 +452,7 @@ def list_skills(include_system: bool = False) -> list[SkillInfo]: # Skip if user has overridden this skill if any(s.name == entry.name for s in skills): continue - skill_md = entry / "SKILL.md" - info = _parse_skill_md(skill_md) - skills.append( - SkillInfo( - name=info["name"], - description=info["description"], - path=entry, - source="system", - ) - ) + skills.append(_parse_skill_md(entry / "SKILL.md", source="system")) return skills @@ -476,7 +484,7 @@ def uninstall_skill(name: str) -> dict: for entry in user_dir.iterdir(): if entry.is_dir() and _validate_skill_dir(entry): info = _parse_skill_md(entry / "SKILL.md") - if info["name"] == clean_name: + if info.name == clean_name: found = entry.resolve() break @@ -507,3 +515,101 @@ def get_skill_info(name: str) -> SkillInfo | None: if skill.name == name: return skill return None + + +def list_skills_by_tag( + tag: str, + include_system: bool = False, +) -> list[SkillInfo]: + """Filter installed skills by tag (case-insensitive). + + Args: + tag: Tag to filter by. + include_system: If True, also include system skills. + + Returns: + List of matching SkillInfo objects. + """ + tag_lower = tag.lower() + return [ + s + for s in list_skills(include_system=include_system) + if tag_lower in [t.lower() for t in s.tags] + ] + + +def get_all_tags(include_system: bool = False) -> list[tuple[str, int]]: + """Return all tags and their counts, sorted by frequency then alphabetically. + + Args: + include_system: If True, also include system skills. + + Returns: + List of (tag, count) tuples sorted by count descending, then name ascending. + """ + from collections import Counter + + counter: Counter[str] = Counter() + for skill in list_skills(include_system=include_system): + for tag in skill.tags: + counter[tag.lower()] += 1 + return sorted(counter.items(), key=lambda x: (-x[1], x[0])) + + +# ── Remote skill index ────────────────────────────────────────────── + +_REMOTE_INDEX_CACHE: dict[str, tuple[float, list[dict]]] = {} +_REMOTE_INDEX_TTL = 600 # 10 minutes + + +def fetch_remote_skill_index( + repo: str = "EvoScientist/EvoSkills", + ref: str | None = None, + path: str = "skills", +) -> list[dict]: + """Fetch skill metadata from a GitHub repo via shallow clone. + + Clones the repo to a temp directory, scans for SKILL.md files, + parses their frontmatter, and returns an index of available skills. + Results are cached for 10 minutes. + + Args: + repo: GitHub repo in owner/repo format. + ref: Branch or tag (None for default branch). + path: Subdirectory containing skills. + + Returns: + List of dicts with keys: name, description, tags, install_source. + """ + cache_key = f"{repo}:{ref or 'default'}:{path}" + now = time.monotonic() + cached = _REMOTE_INDEX_CACHE.get(cache_key) + if cached and (now - cached[0]) < _REMOTE_INDEX_TTL: + return cached[1] + + index: list[dict] = [] + with tempfile.TemporaryDirectory(prefix="evoscientist-browse-") as tmp: + clone_dir = os.path.join(tmp, "repo") + _clone_repo(repo, ref, clone_dir) + + skills_root = Path(clone_dir) / path if path else Path(clone_dir) + if not skills_root.is_dir(): + return index + + found = _scan_skill_dirs(skills_root) + for skill_dir in found: + info = _parse_skill_md(skill_dir / "SKILL.md") + # Compute relative path from clone root for install source + rel = skill_dir.relative_to(Path(clone_dir)) + install_source = f"{repo}@{rel}" + index.append( + { + "name": info.name, + "description": info.description, + "tags": info.tags, + "install_source": install_source, + } + ) + + _REMOTE_INDEX_CACHE[cache_key] = (now, index) + return index diff --git a/tests/test_skills_manager.py b/tests/test_skills_manager.py index 46c43e2..1cb1143 100644 --- a/tests/test_skills_manager.py +++ b/tests/test_skills_manager.py @@ -12,7 +12,10 @@ from EvoScientist.tools.skills_manager import ( _validate_skill_dir, install_skill, list_skills, + list_skills_by_tag, + get_all_tags, uninstall_skill, + fetch_remote_skill_index, ) @@ -76,16 +79,16 @@ class TestParseSkillMd: skill_md = sample_skill_dir / "SKILL.md" result = _parse_skill_md(skill_md) - assert result["name"] == "sample-skill" - assert result["description"] == "A sample skill for testing" + assert result.name == "sample-skill" + assert result.description == "A sample skill for testing" def test_parse_without_frontmatter(self, sample_skill_no_frontmatter): skill_md = sample_skill_no_frontmatter / "SKILL.md" result = _parse_skill_md(skill_md) # Should use directory name as fallback - assert result["name"] == "no-frontmatter-skill" - assert result["description"] == "(no description)" + assert result.name == "no-frontmatter-skill" + assert result.description == "(no description)" def test_parse_with_partial_frontmatter(self, tmp_path): skill_dir = tmp_path / "partial-skill" @@ -101,8 +104,8 @@ name: my-skill ) result = _parse_skill_md(skill_md) - assert result["name"] == "my-skill" - assert result["description"] == "(no description)" + assert result.name == "my-skill" + assert result.description == "(no description)" # ============================================================================= @@ -413,3 +416,256 @@ class TestBatchInstall: assert result["success"] is True assert result.get("batch") is not True # only 1 skill → single install assert result["name"] == "real-skill" + + +# ============================================================================= +# Tests for tag parsing +# ============================================================================= + + +class TestParseSkillMdTags: + """Tests for tag extraction in _parse_skill_md.""" + + def test_parse_with_metadata_tags(self, tmp_path): + """Tags under metadata.tags are extracted.""" + skill_dir = tmp_path / "tagged-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + """--- +name: tagged-skill +description: A skill with tags +metadata: + tags: [core, research, ideation] +--- +""" + ) + result = _parse_skill_md(skill_dir / "SKILL.md") + assert result.tags == ["core", "research", "ideation"] + + def test_parse_with_top_level_tags(self, tmp_path): + """Top-level tags field takes precedence over metadata.tags.""" + skill_dir = tmp_path / "top-tags" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + """--- +name: top-tags +description: Top-level tags +tags: [writing, review] +metadata: + tags: [should, not, appear] +--- +""" + ) + result = _parse_skill_md(skill_dir / "SKILL.md") + assert result.tags == ["writing", "review"] + + def test_parse_no_tags(self, tmp_path): + """Skills without tags return empty list.""" + skill_dir = tmp_path / "no-tags" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + """--- +name: no-tags +description: No tags at all +--- +""" + ) + result = _parse_skill_md(skill_dir / "SKILL.md") + assert result.tags == [] + + def test_parse_comma_string_tags(self, tmp_path): + """Tags given as comma-separated string are split into list.""" + skill_dir = tmp_path / "string-tags" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + """--- +name: string-tags +description: Tags as string +tags: "core, research, writing" +--- +""" + ) + result = _parse_skill_md(skill_dir / "SKILL.md") + assert result.tags == ["core", "research", "writing"] + + def test_parse_no_frontmatter_returns_empty_tags(self, sample_skill_no_frontmatter): + """Skills without frontmatter return empty tags.""" + result = _parse_skill_md(sample_skill_no_frontmatter / "SKILL.md") + assert result.tags == [] + + +# ============================================================================= +# Tests for list_skills_by_tag +# ============================================================================= + + +class TestListSkillsByTag: + """Tests for list_skills_by_tag function.""" + + def _make_tagged_skill(self, parent: Path, name: str, tags: list[str]) -> Path: + d = parent / name + d.mkdir() + tags_yaml = ", ".join(tags) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Skill {name}\n" + f"metadata:\n tags: [{tags_yaml}]\n---\n" + ) + return d + + def test_filter_by_tag(self, tmp_path, temp_skills_dir): + self._make_tagged_skill(temp_skills_dir, "skill-a", ["core", "writing"]) + self._make_tagged_skill(temp_skills_dir, "skill-b", ["core", "research"]) + self._make_tagged_skill(temp_skills_dir, "skill-c", ["research"]) + + with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir): + core = list_skills_by_tag("core") + assert len(core) == 2 + assert {s.name for s in core} == {"skill-a", "skill-b"} + + research = list_skills_by_tag("research") + assert len(research) == 2 + assert {s.name for s in research} == {"skill-b", "skill-c"} + + writing = list_skills_by_tag("writing") + assert len(writing) == 1 + assert writing[0].name == "skill-a" + + def test_filter_case_insensitive(self, tmp_path, temp_skills_dir): + self._make_tagged_skill(temp_skills_dir, "skill-x", ["Core", "Writing"]) + + with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir): + result = list_skills_by_tag("core") + assert len(result) == 1 + assert result[0].name == "skill-x" + + def test_filter_nonexistent_tag(self, tmp_path, temp_skills_dir): + self._make_tagged_skill(temp_skills_dir, "skill-y", ["core"]) + + with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir): + result = list_skills_by_tag("nonexistent") + assert result == [] + + +# ============================================================================= +# Tests for get_all_tags +# ============================================================================= + + +class TestGetAllTags: + """Tests for get_all_tags function.""" + + def _make_tagged_skill(self, parent: Path, name: str, tags: list[str]) -> Path: + d = parent / name + d.mkdir() + tags_yaml = ", ".join(tags) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Skill {name}\n" + f"metadata:\n tags: [{tags_yaml}]\n---\n" + ) + return d + + def test_returns_tags_with_counts(self, tmp_path, temp_skills_dir): + self._make_tagged_skill(temp_skills_dir, "skill-a", ["core", "writing"]) + self._make_tagged_skill(temp_skills_dir, "skill-b", ["core", "research"]) + self._make_tagged_skill(temp_skills_dir, "skill-c", ["research"]) + + with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir): + tags = get_all_tags() + + tag_dict = dict(tags) + assert tag_dict["core"] == 2 + assert tag_dict["research"] == 2 + assert tag_dict["writing"] == 1 + + def test_empty_when_no_skills(self, temp_skills_dir): + with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir): + tags = get_all_tags() + assert tags == [] + + +# ============================================================================= +# Tests for fetch_remote_skill_index +# ============================================================================= + + +class TestFetchRemoteSkillIndex: + """Tests for fetch_remote_skill_index function.""" + + def test_fetch_from_local_clone(self, tmp_path): + """Verify index is built correctly from cloned skills.""" + # Create a fake repo structure + skills_root = tmp_path / "repo" / "skills" + skills_root.mkdir(parents=True) + + for name, tags in [ + ("skill-a", ["core", "writing"]), + ("skill-b", ["core", "research"]), + ]: + d = skills_root / name + d.mkdir() + tags_yaml = ", ".join(tags) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Skill {name}\n" + f"metadata:\n tags: [{tags_yaml}]\n---\n" + ) + + # Mock _clone_repo to copy our fake repo to the temp dir + def fake_clone(repo, ref, dest): + import shutil + + shutil.copytree(tmp_path / "repo", dest) + + with patch( + "EvoScientist.tools.skills_manager._clone_repo", side_effect=fake_clone + ): + # Clear cache to ensure fresh fetch + from EvoScientist.tools.skills_manager import _REMOTE_INDEX_CACHE + + _REMOTE_INDEX_CACHE.clear() + + index = fetch_remote_skill_index(repo="test/repo", path="skills") + + assert len(index) == 2 + names = {s["name"] for s in index} + assert names == {"skill-a", "skill-b"} + + # Verify tags are populated + skill_a = next(s for s in index if s["name"] == "skill-a") + assert "core" in skill_a["tags"] + assert "writing" in skill_a["tags"] + + # Verify install_source is set + assert "test/repo@" in skill_a["install_source"] + + def test_fetch_caches_results(self, tmp_path): + """Second call within TTL uses cache without cloning again.""" + skills_root = tmp_path / "repo" / "skills" + skills_root.mkdir(parents=True) + d = skills_root / "cached-skill" + d.mkdir() + (d / "SKILL.md").write_text( + "---\nname: cached-skill\ndescription: Cached\n" + "metadata:\n tags: [core]\n---\n" + ) + + call_count = 0 + + def fake_clone(repo, ref, dest): + nonlocal call_count + call_count += 1 + import shutil + + shutil.copytree(tmp_path / "repo", dest) + + with patch( + "EvoScientist.tools.skills_manager._clone_repo", side_effect=fake_clone + ): + from EvoScientist.tools.skills_manager import _REMOTE_INDEX_CACHE + + _REMOTE_INDEX_CACHE.clear() + + index1 = fetch_remote_skill_index(repo="cache/test", path="skills") + index2 = fetch_remote_skill_index(repo="cache/test", path="skills") + + assert call_count == 1 # Only cloned once + assert index1 == index2