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
This commit is contained in:
dinos
2026-03-17 13:04:23 +01:00
committed by GitHub
parent d3e877c845
commit 2f855c07c7
7 changed files with 1153 additions and 70 deletions
+12 -1
View File
@@ -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 <tag>)"),
("/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
+205 -3
View File
@@ -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()
+128 -5
View File
@@ -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 <tag>)"),
("/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
+353
View File
@@ -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)
+42 -10
View File
@@ -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'."
+151 -45
View File
@@ -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
+262 -6
View File
@@ -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