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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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'."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user