diff --git a/.gitignore b/.gitignore index d4aea289..31b63b02 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ nuitka-crash-report.xml *.backup main.bin +# Bundled skills removed — skills are now fetched from remote catalog +code_puppy/bundled_skills/ + diff --git a/code_puppy/agents/base_agent.py b/code_puppy/agents/base_agent.py index d3a5fab9..de4a3138 100644 --- a/code_puppy/agents/base_agent.py +++ b/code_puppy/agents/base_agent.py @@ -1891,8 +1891,6 @@ def collect_cancelled_exceptions(exc): except Exception: pass # Don't fail agent run if hook fails - # Import shell process status helper - loop = asyncio.get_running_loop() def schedule_agent_cancel() -> None: diff --git a/code_puppy/api/routers/config.py b/code_puppy/api/routers/config.py index b3be86bf..eb9f9829 100644 --- a/code_puppy/api/routers/config.py +++ b/code_puppy/api/routers/config.py @@ -1,8 +1,9 @@ """Configuration management API endpoints.""" +from typing import Any, Dict, List + from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from typing import Any, Dict, List router = APIRouter() diff --git a/code_puppy/command_line/core_commands.py b/code_puppy/command_line/core_commands.py index 0adfd1f9..443e67ce 100644 --- a/code_puppy/command_line/core_commands.py +++ b/code_puppy/command_line/core_commands.py @@ -856,13 +856,14 @@ def handle_wiggum_stop_command(command: str) -> bool: @register_command( name="skills", description="Manage agent skills - browse, enable, disable skills", - usage="/skills [list|enable|disable]", + usage="/skills [list|install|enable|disable]", category="core", detailed_help="""Launch the skills TUI menu or manage skills with subcommands: /skills - Launch interactive TUI menu - /skills list - Quick text list of all skills - /skills enable - Enable skills integration globally - /skills disable - Disable skills integration globally""", + /skills list - Quick text list of all skills + /skills install - Browse & install skills from remote catalog + /skills enable - Enable skills integration globally + /skills disable - Disable skills integration globally""", ) def handle_skills_command(command: str) -> bool: """Handle the /skills command. @@ -870,6 +871,7 @@ def handle_skills_command(command: str) -> bool: Subcommands: /skills - Launch interactive TUI menu /skills list - Quick text list of all skills (no TUI) + /skills install - Browse & install skills from remote catalog /skills enable - Enable skills globally /skills disable - Disable skills globally """ @@ -930,6 +932,14 @@ def handle_skills_command(command: str) -> bool: return True + elif subcommand == "install": + from code_puppy.command_line.skills_install_menu import ( + run_skills_install_menu, + ) + + run_skills_install_menu() + return True + elif subcommand == "enable": set_skills_enabled(True) emit_success("✅ Skills integration enabled globally") @@ -942,7 +952,7 @@ def handle_skills_command(command: str) -> bool: else: emit_error(f"Unknown subcommand: {subcommand}") - emit_info("Usage: /skills [list|enable|disable]") + emit_info("Usage: /skills [list|install|enable|disable]") return True # No subcommand - launch TUI menu diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index cb1ed410..29b88f3e 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -40,6 +40,7 @@ get_active_model, ) from code_puppy.command_line.pin_command_completion import PinCompleter, UnpinCompleter +from code_puppy.command_line.skills_completion import SkillsCompleter from code_puppy.command_line.utils import list_directory from code_puppy.config import ( COMMAND_HISTORY_FILE, @@ -574,6 +575,7 @@ async def get_input_with_combined_completion( AgentCompleter(trigger="/agent"), AgentCompleter(trigger="/a"), MCPCompleter(trigger="/mcp"), + SkillsCompleter(trigger="/skills"), SlashCompleter(), ] ) diff --git a/code_puppy/command_line/skills_completion.py b/code_puppy/command_line/skills_completion.py new file mode 100644 index 00000000..4d769fb6 --- /dev/null +++ b/code_puppy/command_line/skills_completion.py @@ -0,0 +1,160 @@ +"""Prompt-toolkit completion for `/skills`. + +Mirrors MCPCompleter but simpler: +- Completes subcommands for `/skills ...` +- For `/skills install ...`, completes skill ids from the remote catalog + +This module is intentionally defensive: if the remote catalog isn't available, +completion simply returns no skill ids. +""" + +from __future__ import annotations + +import logging +import time +from typing import Iterable, List + +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.document import Document + +logger = logging.getLogger(__name__) + + +def load_catalog_skill_ids() -> List[str]: + """Load skill ids from the remote catalog (lazy, cached).""" + + try: + from code_puppy.plugins.agent_skills.skill_catalog import catalog + + return [entry.id for entry in catalog.get_all()] + except Exception as e: + logger.debug(f"Could not load skill ids: {e}") + return [] + + +class SkillsCompleter(Completer): + """Completer for /skills subcommands.""" + + def __init__(self, trigger: str = "/skills"): + """Initialize the skills completer. + + Args: + trigger: The slash command prefix to trigger completion. + """ + + self.trigger = trigger + self.subcommands = { + "list": "List all installed skills", + "install": "Browse & install from catalog", + "enable": "Enable skills integration globally", + "disable": "Disable skills integration globally", + "toggle": "Toggle skills system on/off", + "refresh": "Refresh skill cache", + "help": "Show skills help", + } + + self._skill_ids_cache: List[str] | None = None + self._cache_timestamp: float | None = None + + def _get_skill_ids(self) -> List[str]: + """Get skill ids with 30-second cache.""" + + current_time = time.time() + if ( + self._skill_ids_cache is None + or self._cache_timestamp is None + or current_time - self._cache_timestamp > 30 + ): + self._skill_ids_cache = load_catalog_skill_ids() + self._cache_timestamp = current_time + + return self._skill_ids_cache or [] + + def get_completions( + self, document: Document, complete_event + ) -> Iterable[Completion]: + """Yield completions for /skills subcommands and skill ids.""" + + text = document.text + cursor_position = document.cursor_position + text_before_cursor = text[:cursor_position] + + # Only trigger if /skills is at the very beginning of the line + stripped_text = text_before_cursor.lstrip() + if not stripped_text.startswith(self.trigger): + return + + # Find where /skills starts (after any leading whitespace) + skills_pos = text_before_cursor.find(self.trigger) + skills_end = skills_pos + len(self.trigger) + + # Require a space after /skills before showing completions + if ( + skills_end >= len(text_before_cursor) + or text_before_cursor[skills_end] != " " + ): + return + + # Everything after /skills (after the space) + after_skills = text_before_cursor[skills_end + 1 :].strip() + + # If nothing after /skills, show all subcommands + if not after_skills: + for subcommand, description in sorted(self.subcommands.items()): + yield Completion( + subcommand, + start_position=0, + display=subcommand, + display_meta=description, + ) + return + + parts = after_skills.split() + + # Special-case: /skills install + if len(parts) >= 1: + subcommand = parts[0].lower() + + if subcommand == "install": + # Case 1: exactly `install ` -> show all ids + if len(parts) == 1 and text.endswith(" "): + for skill_id in sorted(self._get_skill_ids()): + yield Completion( + skill_id, + start_position=0, + display=skill_id, + display_meta="Skill", + ) + return + + # Case 2: `install ` -> filter ids + if len(parts) == 2 and cursor_position > ( + skills_end + 1 + len(subcommand) + 1 + ): + partial = parts[1] + start_position = -len(partial) + for skill_id in sorted(self._get_skill_ids()): + if skill_id.lower().startswith(partial.lower()): + yield Completion( + skill_id, + start_position=start_position, + display=skill_id, + display_meta="Skill", + ) + return + + # If we only have one part and no trailing space, complete subcommands + if len(parts) == 1 and not text.endswith(" "): + partial = parts[0] + for subcommand, description in sorted(self.subcommands.items()): + if subcommand.startswith(partial): + yield Completion( + subcommand, + start_position=-(len(partial)), + display=subcommand, + display_meta=description, + ) + return + + # Otherwise, no further completion. + return diff --git a/code_puppy/command_line/skills_install_menu.py b/code_puppy/command_line/skills_install_menu.py new file mode 100644 index 00000000..7c7a550a --- /dev/null +++ b/code_puppy/command_line/skills_install_menu.py @@ -0,0 +1,664 @@ +"""Interactive terminal UI for browsing and installing remote agent skills. + +Launched from `/skills install` (wiring may live elsewhere). Provides a +split-panel prompt_toolkit UI: +- Left: categories, then skills within a category +- Right: live details preview for the current selection + +Installation happens after the TUI exits, with a confirmation prompt via +`safe_input()`, and uses `download_and_install_skill()` to fetch and extract +remote ZIPs. + +This module is intentionally defensive: if the remote catalog isn't available, +it shows an empty menu and returns False. +""" + +import logging +import sys +import time +from pathlib import Path +from typing import List, Optional + +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout import Dimension, Layout, VSplit, Window +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.widgets import Frame + +from code_puppy.command_line.utils import safe_input +from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning +from code_puppy.plugins.agent_skills.downloader import download_and_install_skill +from code_puppy.plugins.agent_skills.installer import InstallResult +from code_puppy.plugins.agent_skills.skill_catalog import SkillCatalogEntry, catalog +from code_puppy.tools.command_runner import set_awaiting_user_input + +logger = logging.getLogger(__name__) + +PAGE_SIZE = 12 + + +def is_skill_installed(skill_id: str) -> bool: + """Return True if the skill is already installed locally.""" + + return (Path.home() / ".code_puppy" / "skills" / skill_id / "SKILL.md").is_file() + + +def _format_bytes(num_bytes: int) -> str: + """Format bytes into a human-readable string.""" + + try: + size = float(max(0, int(num_bytes))) + except Exception: + return "0 B" + + for unit in ("B", "KB", "MB", "GB"): + if size < 1024.0 or unit == "GB": + if unit == "B": + return f"{int(size)} {unit}" + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} GB" + + +def _wrap_text(text: str, width: int) -> List[str]: + """Simple word-wrap for display in the details panel.""" + + if not text: + return [] + + words = text.split() + lines: list[str] = [] + current = "" + + for word in words: + if not current: + current = word + continue + + if len(current) + 1 + len(word) > width: + lines.append(current) + current = word + else: + current = f"{current} {word}" + + if current: + lines.append(current) + + return lines + + +def _category_key(category: str) -> str: + """Normalize a category string for icon lookup.""" + + return "".join(ch for ch in (category or "").casefold() if ch.isalnum()) + + +class SkillsInstallMenu: + """Interactive TUI for browsing and installing remote skills.""" + + def __init__(self): + """Initialize the skills install menu with catalog data.""" + + self.catalog = catalog + self.categories: List[str] = [] + self.current_category: Optional[str] = None + self.current_skills: List[SkillCatalogEntry] = [] + + # State + self.view_mode = "categories" # categories | skills + self.selected_category_idx = 0 + self.selected_skill_idx = 0 + self.current_page = 0 + self.result: Optional[str] = None + self.pending_entry: Optional[SkillCatalogEntry] = None + + # UI controls + self.menu_control: Optional[FormattedTextControl] = None + self.preview_control: Optional[FormattedTextControl] = None + + self._initialize_catalog() + + def _initialize_catalog(self) -> None: + """Load categories from the remote-backed catalog.""" + + try: + self.categories = self.catalog.list_categories() if self.catalog else [] + except Exception as e: + emit_error(f"Skill catalog not available: {e}") + self.categories = [] + + def _get_category_icon(self, category: str) -> str: + """Return an emoji icon for a skill category name.""" + + icons = { + "data": "📊", + "finance": "💰", + "legal": "⚖️", + "office": "📄", + "productmanagement": "📦", + "sales": "💼", + "biology": "🧬", + } + return icons.get(_category_key(category), "📁") + + def _get_current_category(self) -> Optional[str]: + """Get the currently highlighted category name.""" + + if 0 <= self.selected_category_idx < len(self.categories): + return self.categories[self.selected_category_idx] + return None + + def _get_current_skill(self) -> Optional[SkillCatalogEntry]: + """Get the currently highlighted skill entry.""" + + if self.view_mode == "skills" and self.current_skills: + if 0 <= self.selected_skill_idx < len(self.current_skills): + return self.current_skills[self.selected_skill_idx] + return None + + def _render_navigation_hints(self, lines: List) -> None: + """Render keyboard shortcut hints at the bottom.""" + + lines.append(("", "\n")) + lines.append(("fg:ansibrightblack", " ↑/↓ ")) + lines.append(("", "Navigate ")) + lines.append(("fg:ansibrightblack", "←/→ ")) + lines.append(("", "Page\n")) + + if self.view_mode == "categories": + lines.append(("fg:ansigreen", " Enter ")) + lines.append(("", "Browse Skills\n")) + else: + lines.append(("fg:ansigreen", " Enter ")) + lines.append(("", "Install Skill\n")) + lines.append(("fg:ansibrightblack", " Esc/Back ")) + lines.append(("", "Back\n")) + + lines.append(("fg:ansired", " Ctrl+C ")) + lines.append(("", "Cancel")) + + def _render_category_list(self) -> List: + """Render the left panel with category navigation.""" + + lines = [] + + lines.append(("bold cyan", " 📂 CATEGORIES")) + lines.append(("", "\n\n")) + + if not self.categories: + lines.append(("fg:ansiyellow", " No remote categories available.")) + lines.append(("", "\n")) + lines.append( + ( + "fg:ansibrightblack", + " (Remote catalog unavailable or empty)\n", + ) + ) + self._render_navigation_hints(lines) + return lines + + total_pages = (len(self.categories) + PAGE_SIZE - 1) // PAGE_SIZE + start_idx = self.current_page * PAGE_SIZE + end_idx = min(start_idx + PAGE_SIZE, len(self.categories)) + + for i in range(start_idx, end_idx): + category = self.categories[i] + is_selected = i == self.selected_category_idx + icon = self._get_category_icon(category) + count = 0 + try: + count = ( + len(self.catalog.get_by_category(category)) if self.catalog else 0 + ) + except Exception: + count = 0 + + prefix = " > " if is_selected else " " + label = f"{prefix}{icon} {category} ({count})" + + if is_selected: + lines.append(("fg:ansibrightcyan bold", label)) + else: + lines.append(("fg:ansibrightblack", label)) + lines.append(("", "\n")) + + lines.append(("", "\n")) + if total_pages > 1: + lines.append( + ("fg:ansibrightblack", f" Page {self.current_page + 1}/{total_pages}") + ) + lines.append(("", "\n")) + + self._render_navigation_hints(lines) + return lines + + def _render_skill_list(self) -> List: + """Render the middle panel with skills in the selected category.""" + + lines = [] + + if not self.current_category: + lines.append(("fg:ansiyellow", " No category selected.")) + lines.append(("", "\n\n")) + self._render_navigation_hints(lines) + return lines + + icon = self._get_category_icon(self.current_category) + lines.append(("bold cyan", f" {icon} {self.current_category.upper()}")) + lines.append(("", "\n\n")) + + if not self.current_skills: + lines.append(("fg:ansiyellow", " No skills in this category.")) + lines.append(("", "\n\n")) + self._render_navigation_hints(lines) + return lines + + total_pages = (len(self.current_skills) + PAGE_SIZE - 1) // PAGE_SIZE + start_idx = self.current_page * PAGE_SIZE + end_idx = min(start_idx + PAGE_SIZE, len(self.current_skills)) + + for i in range(start_idx, end_idx): + entry = self.current_skills[i] + is_selected = i == self.selected_skill_idx + + installed = is_skill_installed(entry.id) + status_icon = "✓" if installed else "○" + status_style = "fg:ansigreen" if installed else "fg:ansibrightblack" + + prefix = " > " if is_selected else " " + label = f"{prefix}{status_icon} {entry.display_name}" + + if is_selected: + lines.append(("fg:ansibrightcyan bold", label)) + else: + lines.append((status_style, label)) + + lines.append(("", "\n")) + + lines.append(("", "\n")) + if total_pages > 1: + lines.append( + ("fg:ansibrightblack", f" Page {self.current_page + 1}/{total_pages}") + ) + lines.append(("", "\n")) + + self._render_navigation_hints(lines) + return lines + + def _render_details(self) -> List: + """Render the right panel with details for the selected skill.""" + + lines = [] + + lines.append(("bold cyan", " 📋 DETAILS")) + lines.append(("", "\n\n")) + + if self.view_mode == "categories": + category = self._get_current_category() + if not category: + lines.append(("fg:ansiyellow", " No category selected.")) + return lines + + icon = self._get_category_icon(category) + lines.append(("bold", f" {icon} {category}")) + lines.append(("", "\n\n")) + + skills = [] + try: + skills = self.catalog.get_by_category(category) if self.catalog else [] + except Exception: + skills = [] + + lines.append(("fg:ansibrightblack", f" {len(skills)} skills available")) + lines.append(("", "\n\n")) + + # Show a preview of the first few skills + if skills: + lines.append(("bold", " Preview:")) + lines.append(("", "\n")) + for entry in skills[:6]: + lines.append(("fg:ansibrightblack", f" • {entry.display_name}")) + lines.append(("", "\n")) + + return lines + + entry = self._get_current_skill() + if not entry: + lines.append(("fg:ansiyellow", " No skill selected.")) + return lines + + installed = is_skill_installed(entry.id) + installed_text = "Installed" if installed else "Not installed" + installed_style = "fg:ansigreen" if installed else "fg:ansiyellow" + + lines.append(("bold", f" {entry.display_name}")) + lines.append(("", "\n")) + lines.append((installed_style, f" {installed_text}")) + lines.append(("", "\n\n")) + + lines.append(("bold", " ID:")) + lines.append(("", "\n")) + lines.append(("fg:ansibrightblack", f" {entry.id}")) + lines.append(("", "\n\n")) + + lines.append(("bold", " Description:")) + lines.append(("", "\n")) + desc = entry.description or "No description available" + for line in _wrap_text(desc, 56): + lines.append(("fg:ansibrightblack", f" {line}")) + lines.append(("", "\n")) + lines.append(("", "\n")) + + lines.append(("bold", " Category:")) + lines.append(("", "\n")) + lines.append(("fg:ansibrightblack", f" {entry.category}")) + lines.append(("", "\n\n")) + + lines.append(("bold", " Tags:")) + lines.append(("", "\n")) + tags = entry.tags or [] + lines.append(("fg:ansicyan", f" {', '.join(tags) if tags else '(none)'}")) + lines.append(("", "\n\n")) + + lines.append(("bold", " Contents:")) + lines.append(("", "\n")) + lines.append( + ( + "fg:ansibrightblack", + f" scripts: {'yes' if entry.has_scripts else 'no'}", + ) + ) + lines.append(("", "\n")) + lines.append( + ( + "fg:ansibrightblack", + f" references: {'yes' if entry.has_references else 'no'}", + ) + ) + lines.append(("", "\n")) + lines.append(("fg:ansibrightblack", f" files: {entry.file_count}")) + lines.append(("", "\n\n")) + + lines.append(("bold", " Download:")) + lines.append(("", "\n")) + lines.append( + ( + "fg:ansibrightblack", + f" size: {_format_bytes(entry.zip_size_bytes)}", + ) + ) + lines.append(("", "\n")) + lines.append(("fg:ansibrightblack", f" url: {entry.download_url}")) + lines.append(("", "\n")) + + return lines + + def update_display(self) -> None: + """Refresh all three panels of the TUI display.""" + + if self.view_mode == "categories": + self.menu_control.text = self._render_category_list() + else: + self.menu_control.text = self._render_skill_list() + + self.preview_control.text = self._render_details() + + def _enter_category(self) -> None: + """Enter the currently highlighted category to browse skills.""" + + category = self._get_current_category() + if not category or not self.catalog: + return + + self.current_category = category + try: + self.current_skills = self.catalog.get_by_category(category) + except Exception: + self.current_skills = [] + + self.view_mode = "skills" + self.selected_skill_idx = 0 + self.current_page = 0 + self.update_display() + + def _go_back_to_categories(self) -> None: + """Navigate back from skill list to category list.""" + + self.view_mode = "categories" + self.current_category = None + self.current_skills = [] + self.selected_skill_idx = 0 + self.current_page = 0 + self.update_display() + + def _select_current_skill(self) -> None: + """Download and install the currently highlighted skill.""" + + entry = self._get_current_skill() + if entry: + self.pending_entry = entry + self.result = "pending_install" + + def run(self) -> bool: + """Run the skills install menu. + + Returns: + True if a skill was installed, False otherwise. + """ + + # Build UI + self.menu_control = FormattedTextControl(text="") + self.preview_control = FormattedTextControl(text="") + + menu_window = Window( + content=self.menu_control, wrap_lines=True, width=Dimension(weight=35) + ) + preview_window = Window( + content=self.preview_control, wrap_lines=True, width=Dimension(weight=65) + ) + + menu_frame = Frame(menu_window, width=Dimension(weight=35), title="Browse") + preview_frame = Frame( + preview_window, width=Dimension(weight=65), title="Details" + ) + + root_container = VSplit([menu_frame, preview_frame]) + + kb = KeyBindings() + + @kb.add("up") + def _(event): + """Move cursor up.""" + + if self.view_mode == "categories": + if self.selected_category_idx > 0: + self.selected_category_idx -= 1 + self.current_page = self.selected_category_idx // PAGE_SIZE + else: + if self.selected_skill_idx > 0: + self.selected_skill_idx -= 1 + self.current_page = self.selected_skill_idx // PAGE_SIZE + self.update_display() + + @kb.add("down") + def _(event): + """Move cursor down.""" + + if self.view_mode == "categories": + if self.selected_category_idx < len(self.categories) - 1: + self.selected_category_idx += 1 + self.current_page = self.selected_category_idx // PAGE_SIZE + else: + if self.selected_skill_idx < len(self.current_skills) - 1: + self.selected_skill_idx += 1 + self.current_page = self.selected_skill_idx // PAGE_SIZE + self.update_display() + + @kb.add("left") + def _(event): + """Navigate to previous page.""" + + if self.current_page > 0: + self.current_page -= 1 + if self.view_mode == "categories": + self.selected_category_idx = self.current_page * PAGE_SIZE + else: + self.selected_skill_idx = self.current_page * PAGE_SIZE + self.update_display() + + @kb.add("right") + def _(event): + """Navigate to next page.""" + + if self.view_mode == "categories": + total_items = len(self.categories) + else: + total_items = len(self.current_skills) + + total_pages = (total_items + PAGE_SIZE - 1) // PAGE_SIZE + if self.current_page < total_pages - 1: + self.current_page += 1 + if self.view_mode == "categories": + self.selected_category_idx = self.current_page * PAGE_SIZE + else: + self.selected_skill_idx = self.current_page * PAGE_SIZE + self.update_display() + + @kb.add("enter") + def _(event): + """Select/enter the current item.""" + + if self.view_mode == "categories": + self._enter_category() + else: + self._select_current_skill() + event.app.exit() + + @kb.add("escape") + def _(event): + """Go back.""" + + if self.view_mode == "skills": + self._go_back_to_categories() + + @kb.add("backspace") + def _(event): + """Go back.""" + + if self.view_mode == "skills": + self._go_back_to_categories() + + @kb.add("c-c") + def _(event): + """Quit the menu.""" + + event.app.exit() + + layout = Layout(root_container) + app = Application( + layout=layout, + key_bindings=kb, + full_screen=False, + mouse_support=False, + ) + + set_awaiting_user_input(True) + + # Enter alternate screen buffer + sys.stdout.write("\033[?1049h") + sys.stdout.write("\033[2J\033[H") + sys.stdout.flush() + time.sleep(0.05) + + try: + self.update_display() + sys.stdout.write("\033[2J\033[H") + sys.stdout.flush() + + app.run(in_thread=True) + + finally: + sys.stdout.write("\033[?1049l") + sys.stdout.flush() + + # Flush any buffered input to prevent stale keypresses + try: + import termios + + termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH) + except (ImportError, termios.error, OSError): + pass # Windows or not a tty + + # Small delay to let terminal settle before any output + time.sleep(0.1) + set_awaiting_user_input(False) + + # Handle install after TUI exits + if self.result == "pending_install" and self.pending_entry: + return _prompt_and_install(self.pending_entry) + + emit_info("✓ Exited skills install browser") + return False + + +def _prompt_and_install(entry: SkillCatalogEntry) -> bool: + """Prompt for confirmation and install the given skill.""" + + installed = is_skill_installed(entry.id) + size_str = _format_bytes(entry.zip_size_bytes) + + try: + if installed: + answer = safe_input( + f"Skill '{entry.display_name}' is already installed. Reinstall ({size_str})? [y/N] " + ) + if answer.strip().lower() not in {"y", "yes"}: + emit_info("Installation cancelled") + return False + force = True + else: + answer = safe_input( + f"Install skill '{entry.display_name}' ({size_str})? [y/N] " + ) + if answer.strip().lower() not in {"y", "yes"}: + emit_info("Installation cancelled") + return False + force = False + + except (KeyboardInterrupt, EOFError): + emit_warning("Installation cancelled") + return False + + emit_info(f"Downloading: {entry.display_name} ({size_str})") + + result: InstallResult + try: + result = download_and_install_skill( + skill_name=entry.id, + download_url=entry.download_url, + force=force, + ) + except Exception as e: + logger.exception(f"Unexpected error during skill install: {e}") + emit_error(f"Installation error: {e}") + return False + + if result.success: + emit_success(result.message) + if result.installed_path: + emit_info(f"Installed to: {result.installed_path}") + return True + + emit_error(result.message) + return False + + +def run_skills_install_menu() -> bool: + """Run the bundled skills install menu. + + Returns: + True if a skill was installed, False otherwise. + """ + + menu = SkillsInstallMenu() + return menu.run() diff --git a/code_puppy/command_line/skills_menu.py b/code_puppy/command_line/skills_menu.py index 7754fa45..8b771692 100644 --- a/code_puppy/command_line/skills_menu.py +++ b/code_puppy/command_line/skills_menu.py @@ -188,6 +188,8 @@ def _render_navigation_hints(self, lines: List) -> None: lines.append(("", "Add Dir ")) lines.append(("fg:ansiyellow", " Ctrl+D ")) lines.append(("", "Show Dirs\n")) + lines.append(("fg:ansimagenta", " i ")) + lines.append(("", "Install from catalog\n")) lines.append(("fg:ansiyellow", " r ")) lines.append(("", "Refresh ")) lines.append(("fg:ansired", " q ")) @@ -412,6 +414,12 @@ def _(event): self.result = "show_directories" event.app.exit() + @kb.add("i") + def _(event): + """Install skills from catalog.""" + self.result = "install" + event.app.exit() + @kb.add("q") @kb.add("escape") def _(event): @@ -569,6 +577,16 @@ def show_skills_menu() -> bool: # Re-run the menu continue + elif result == "install": + from code_puppy.command_line.skills_install_menu import ( + run_skills_install_menu, + ) + + install_result = run_skills_install_menu() + if install_result: + changes_made = True + continue # Re-run the skills menu after install + elif result == "changed": changes_made = True break diff --git a/code_puppy/mcp_/registry.py b/code_puppy/mcp_/registry.py index 1e6ff236..a109bd53 100644 --- a/code_puppy/mcp_/registry.py +++ b/code_puppy/mcp_/registry.py @@ -13,6 +13,7 @@ from typing import Dict, List, Optional from code_puppy import config + from .managed_server import ServerConfig # Configure logging diff --git a/code_puppy/plugins/agent_skills/downloader.py b/code_puppy/plugins/agent_skills/downloader.py new file mode 100644 index 00000000..1286c48f --- /dev/null +++ b/code_puppy/plugins/agent_skills/downloader.py @@ -0,0 +1,392 @@ +"""Remote skill downloader/installer. + +Downloads a remote skill ZIP and installs it into the local skills directory. + +Security notes: +- Defends against zip-slip path traversal. +- Defends (somewhat) against zip bombs by capping total uncompressed size. + +This module never raises to callers; failures are returned as InstallResult. +""" + +from __future__ import annotations + +import logging +import shutil +import tempfile +import zipfile +from pathlib import Path +from typing import Optional + +import httpx + +from code_puppy.plugins.agent_skills.discovery import refresh_skill_cache +from code_puppy.plugins.agent_skills.installer import InstallResult + +logger = logging.getLogger(__name__) + +_DEFAULT_SKILLS_DIR = Path.home() / ".code_puppy" / "skills" +_MAX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024 # 50MB + + +def _zip_entry_parts(name: str) -> list[str]: + """Return safe-ish path parts for a zip entry. + + Zip files use POSIX-style separators, but malicious zips sometimes include + backslashes. We normalize to '/' then split. + """ + + normalized = name.replace("\\", "/") + return [part for part in normalized.split("/") if part not in {"", "."}] + + +def _safe_rmtree(path: Path) -> bool: + """Remove a directory tree, logging errors instead of raising.""" + + try: + if not path.exists(): + return True + shutil.rmtree(path) + return True + except Exception as e: + logger.warning(f"Failed to remove directory {path}: {e}") + return False + + +def _download_to_file(url: str, dest: Path) -> bool: + """Download a URL to a local file path with streaming.""" + + headers = { + "Accept": "application/zip, application/octet-stream, */*", + "User-Agent": "code-puppy/skill-downloader", + } + + try: + dest.parent.mkdir(parents=True, exist_ok=True) + + with httpx.Client(timeout=30, headers=headers, follow_redirects=True) as client: + with client.stream("GET", url) as response: + response.raise_for_status() + + with dest.open("wb") as f: + for chunk in response.iter_bytes(): + if chunk: + f.write(chunk) + + logger.info(f"Downloaded skill zip to {dest}") + return True + + except httpx.HTTPStatusError as e: + logger.warning( + "Skill download failed with HTTP status: " + f"{e.response.status_code} {e.response.reason_phrase}" + ) + return False + except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as e: + logger.warning(f"Skill download network failure: {e}") + return False + except Exception as e: + logger.exception(f"Unexpected error downloading {url}: {e}") + return False + + +def _is_within_directory(base_dir: Path, candidate: Path) -> bool: + """Check that a path is safely contained within a directory.""" + + try: + base_resolved = base_dir.resolve() + candidate_resolved = candidate.resolve() + candidate_resolved.relative_to(base_resolved) + return True + except Exception: + return False + + +def _validate_zip_safety(zf: zipfile.ZipFile) -> Optional[str]: + """Return an error message if unsafe, otherwise None.""" + + total_uncompressed = 0 + + for info in zf.infolist(): + # Directory entries are fine. + if info.is_dir(): + continue + + total_uncompressed += int(info.file_size or 0) + if total_uncompressed > _MAX_UNCOMPRESSED_BYTES: + return ( + "ZIP appears too large when uncompressed " + f"(>{_MAX_UNCOMPRESSED_BYTES} bytes)" + ) + + # Basic zip-slip protection: reject absolute paths and parent traversals. + name = info.filename + normalized = name.replace("\\", "/") + if normalized.startswith("/"): + return f"Unsafe zip entry path (absolute): {name}" + + parts = _zip_entry_parts(name) + if ".." in parts: + return f"Unsafe zip entry path (traversal): {name}" + + return None + + +def _safe_extract_zip(zf: zipfile.ZipFile, extract_dir: Path) -> bool: + """Safely extract zip contents into extract_dir.""" + + try: + extract_dir.mkdir(parents=True, exist_ok=True) + + for info in zf.infolist(): + parts = _zip_entry_parts(info.filename) + + # Skip weird metadata folders. + if parts and parts[0] == "__MACOSX": + continue + + dest_path = extract_dir.joinpath(*parts) + + if not _is_within_directory(extract_dir, dest_path): + logger.warning( + f"Blocked zip entry outside extraction dir: {info.filename}" + ) + return False + + if info.is_dir(): + dest_path.mkdir(parents=True, exist_ok=True) + continue + + dest_path.parent.mkdir(parents=True, exist_ok=True) + + with zf.open(info, "r") as src, dest_path.open("wb") as dst: + shutil.copyfileobj(src, dst) + + return True + + except Exception as e: + logger.exception(f"Failed to extract zip safely: {e}") + return False + + +def _determine_extracted_root(extract_dir: Path) -> Optional[Path]: + """Determine where the skill files live inside an extracted zip. + + Supports: + - Files at the zip root + - Files inside a single top-level folder + + Returns: + Path to the directory containing SKILL.md, or None. + """ + + try: + if (extract_dir / "SKILL.md").is_file(): + return extract_dir + + children = [p for p in extract_dir.iterdir() if p.name != "__MACOSX"] + dirs = [p for p in children if p.is_dir()] + files = [p for p in children if p.is_file()] + + # If it's root-level but SKILL.md missing, no good. + if files: + return None + + if len(dirs) == 1: + candidate = dirs[0] + if (candidate / "SKILL.md").is_file(): + return candidate + + return None + + except Exception as e: + logger.warning(f"Failed to inspect extracted zip directory {extract_dir}: {e}") + return None + + +def _stage_normalized_install( + extracted_root: Path, skill_name: str, staging_base: Path +) -> Optional[Path]: + """Copy extracted content into staging_base/.""" + + try: + staged_skill_dir = staging_base / skill_name + if staged_skill_dir.exists(): + _safe_rmtree(staged_skill_dir) + + shutil.copytree(extracted_root, staged_skill_dir) + + if not (staged_skill_dir / "SKILL.md").is_file(): + logger.warning( + f"Staged skill is missing SKILL.md: {(staged_skill_dir / 'SKILL.md')}" + ) + return None + + return staged_skill_dir + + except Exception as e: + logger.exception(f"Failed to stage normalized install for {skill_name}: {e}") + return None + + +def download_and_install_skill( + skill_name: str, + download_url: str, + target_dir: Optional[Path] = None, + force: bool = False, +) -> InstallResult: + """Download and install a remote skill zip. + + Args: + skill_name: Skill name (directory name under target_dir). + download_url: Absolute URL to the skill .zip. + target_dir: Base skills directory. Defaults to ~/.code_puppy/skills. + force: If True, delete any existing install first. + + Returns: + InstallResult indicating success/failure. + """ + + skill_name = skill_name.strip() + if not skill_name: + return InstallResult(success=False, message="skill_name is required") + + # Prevent path traversal via skill_name. + if Path(skill_name).name != skill_name or skill_name in {".", ".."}: + return InstallResult( + success=False, message="skill_name must be a simple directory name" + ) + + base_dir = target_dir or _DEFAULT_SKILLS_DIR + skill_dir = base_dir / skill_name + + try: + if skill_dir.exists(): + if not force: + return InstallResult( + success=False, + message=f"Skill already installed at {skill_dir} (use force=True to reinstall)", + installed_path=skill_dir, + ) + + logger.info( + f"Force reinstall enabled; removing existing skill at {skill_dir}" + ) + if not _safe_rmtree(skill_dir): + return InstallResult( + success=False, + message=f"Failed to remove existing skill directory: {skill_dir}", + installed_path=skill_dir, + ) + + base_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(prefix="code_puppy_skill_") as tmp: + tmp_dir = Path(tmp) + tmp_zip = tmp_dir / f"{skill_name}.zip" + extract_dir = tmp_dir / "extracted" + staging_dir = tmp_dir / "staging" + staging_dir.mkdir(parents=True, exist_ok=True) + + if not _download_to_file(download_url, tmp_zip): + return InstallResult( + success=False, + message=f"Failed to download skill zip from {download_url}", + ) + + try: + with zipfile.ZipFile(tmp_zip, "r") as zf: + unsafe_reason = _validate_zip_safety(zf) + if unsafe_reason: + logger.warning( + f"Rejected unsafe zip for {skill_name}: {unsafe_reason}" + ) + return InstallResult( + success=False, + message=f"Rejected unsafe zip: {unsafe_reason}", + ) + + if not _safe_extract_zip(zf, extract_dir): + return InstallResult( + success=False, + message="Failed to extract skill zip safely", + ) + except zipfile.BadZipFile: + logger.warning(f"Downloaded file is not a valid zip: {tmp_zip}") + return InstallResult( + success=False, message="Downloaded file is not a valid zip" + ) + except Exception as e: + logger.exception(f"Failed to open/extract zip for {skill_name}: {e}") + return InstallResult(success=False, message="Failed to extract zip") + + extracted_root = _determine_extracted_root(extract_dir) + if extracted_root is None: + logger.warning( + "Extracted zip layout not recognized or missing SKILL.md. " + f"extract_dir={extract_dir}" + ) + return InstallResult( + success=False, + message="Extracted zip missing SKILL.md or has unexpected layout", + ) + + staged_skill_dir = _stage_normalized_install( + extracted_root=extracted_root, + skill_name=skill_name, + staging_base=staging_dir, + ) + if staged_skill_dir is None: + return InstallResult( + success=False, + message="Failed to stage extracted skill (missing SKILL.md)", + ) + + # Move staged install into final destination. + try: + if skill_dir.exists(): + # Shouldn't happen (handled earlier), but be safe. + if force: + _safe_rmtree(skill_dir) + else: + return InstallResult( + success=False, + message=f"Skill directory already exists: {skill_dir}", + installed_path=skill_dir, + ) + + shutil.move(str(staged_skill_dir), str(skill_dir)) + except Exception as e: + logger.exception(f"Failed to install skill into {skill_dir}: {e}") + # Cleanup partial install. + _safe_rmtree(skill_dir) + return InstallResult( + success=False, message="Failed to move skill into place" + ) + + # Post-install verification. + if not (skill_dir / "SKILL.md").is_file(): + logger.warning(f"Installed skill missing SKILL.md: {skill_dir}") + _safe_rmtree(skill_dir) + return InstallResult( + success=False, + message="Installed skill is missing SKILL.md", + installed_path=skill_dir, + ) + + try: + refresh_skill_cache() + except Exception as e: + # Cache refresh failure should not poison a successful install. + logger.warning(f"Skill installed but failed to refresh skill cache: {e}") + + logger.info(f"Installed skill '{skill_name}' into {skill_dir}") + return InstallResult( + success=True, + message=f"Installed skill '{skill_name}'", + installed_path=skill_dir, + ) + + except Exception as e: + logger.exception(f"Unexpected error installing skill {skill_name}: {e}") + return InstallResult(success=False, message="Unexpected error installing skill") diff --git a/code_puppy/plugins/agent_skills/installer.py b/code_puppy/plugins/agent_skills/installer.py new file mode 100644 index 00000000..9f8dd709 --- /dev/null +++ b/code_puppy/plugins/agent_skills/installer.py @@ -0,0 +1,22 @@ +"""Agent skills installation helpers. + +This module currently provides the shared InstallResult type used by skill +installers (e.g. local installers, remote zip downloaders). + +It is intentionally small so other modules can depend on a stable result shape. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass(frozen=True, slots=True) +class InstallResult: + """Result of a skill install attempt.""" + + success: bool + message: str + installed_path: Optional[Path] = None diff --git a/code_puppy/plugins/agent_skills/remote_catalog.py b/code_puppy/plugins/agent_skills/remote_catalog.py new file mode 100644 index 00000000..bdeeaff9 --- /dev/null +++ b/code_puppy/plugins/agent_skills/remote_catalog.py @@ -0,0 +1,322 @@ +"""Remote skills catalog client. + +Fetches the remote skills catalog JSON and exposes a cached, parsed view. + +Design goals: +- Never crash the app (defensive parsing + broad error handling). +- Local caching with TTL for fast startup and offline use. +- Synchronous networking only (httpx.Client). + +Schema source: +https://www.llmspec.dev/skills/skills.json +""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional +from urllib.parse import urljoin + +import httpx + +logger = logging.getLogger(__name__) + +SKILLS_JSON_URL = "https://www.llmspec.dev/skills/skills.json" + +_CACHE_DIR = Path.home() / ".code_puppy" / "cache" +_CACHE_PATH = _CACHE_DIR / "skills_catalog.json" +_CACHE_TTL_SECONDS = 30 * 60 + + +@dataclass(frozen=True, slots=True) +class RemoteSkillEntry: + """Flattened remote skill entry.""" + + name: str + description: str + group: str + download_url: str + zip_size_bytes: int + file_count: int + has_scripts: bool + has_references: bool + has_license: bool + + +@dataclass(frozen=True, slots=True) +class RemoteCatalogData: + """Parsed remote catalog. + + Attributes: + version: Catalog version string. + base_url: Base URL used to build absolute download_url values. + total_skills: Total number of skills in the remote catalog. + groups: Raw group objects from the JSON (kept as dicts for flexibility). + entries: Flattened list of all skills across all groups. + """ + + version: str + base_url: str + total_skills: int + groups: list[dict[str, Any]] + entries: list[RemoteSkillEntry] + + +def _safe_int(value: Any, default: int = 0) -> int: + """Convert value to int, returning default on failure.""" + + try: + if value is None: + return default + return int(value) + except Exception: + return default + + +def _safe_bool(value: Any, default: bool = False) -> bool: + """Convert value to bool, returning default on failure.""" + + if value is None: + return default + return bool(value) + + +def _cache_is_fresh(cache_path: Path, ttl_seconds: int) -> bool: + """Check whether the on-disk catalog cache is within TTL.""" + + try: + if not cache_path.exists(): + return False + age_seconds = time.time() - cache_path.stat().st_mtime + return age_seconds <= ttl_seconds + except Exception as e: + logger.debug(f"Failed to check cache age for {cache_path}: {e}") + return False + + +def _read_cache(cache_path: Path) -> Optional[dict[str, Any]]: + """Read and deserialize the cached catalog JSON from disk.""" + + try: + if not cache_path.exists(): + return None + raw = cache_path.read_text(encoding="utf-8") + data = json.loads(raw) + if not isinstance(data, dict): + logger.warning(f"Cache JSON is not an object: {cache_path}") + return None + return data + except Exception as e: + logger.warning(f"Failed to read cache {cache_path}: {e}") + return None + + +def _write_cache(cache_path: Path, data: dict[str, Any]) -> bool: + """Serialize and write catalog JSON to the disk cache.""" + + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + # Stable formatting so diffs are readable when debugging. + cache_path.write_text( + json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return True + except Exception as e: + logger.warning(f"Failed to write cache {cache_path}: {e}") + return False + + +def _fetch_remote_json(url: str) -> Optional[dict[str, Any]]: + """Fetch the skills catalog JSON from the remote URL.""" + + headers = { + "Accept": "application/json", + "User-Agent": "code-puppy/remote-catalog", + } + + try: + with httpx.Client(timeout=15, headers=headers) as client: + response = client.get(url) + response.raise_for_status() + data = response.json() + + if not isinstance(data, dict): + logger.error(f"Remote catalog JSON was not an object. Got: {type(data)}") + return None + + return data + + except httpx.HTTPStatusError as e: + logger.warning( + "Remote catalog request returned bad status: " + f"{e.response.status_code} {e.response.reason_phrase}" + ) + return None + except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as e: + logger.warning(f"Remote catalog network failure: {e}") + return None + except json.JSONDecodeError as e: + logger.warning(f"Remote catalog returned invalid JSON: {e}") + return None + except Exception as e: + logger.exception(f"Unexpected error fetching remote catalog: {e}") + return None + + +def _parse_catalog(raw: dict[str, Any]) -> Optional[RemoteCatalogData]: + """Parse raw JSON dicts into a list of RemoteSkillEntry objects.""" + + try: + version = str(raw.get("version") or "") + base_url = str(raw.get("base_url") or "") + total_skills = _safe_int(raw.get("total_skills"), default=0) + + raw_groups = raw.get("groups") + if not isinstance(raw_groups, list): + logger.warning("Remote catalog 'groups' missing or not a list") + raw_groups = [] + + groups: list[dict[str, Any]] = [] + entries: list[RemoteSkillEntry] = [] + + # Ensure urljoin behaves (needs trailing slash on base). + base_for_join = base_url.rstrip("/") + "/" if base_url else "" + + for group_obj in raw_groups: + if not isinstance(group_obj, dict): + continue + groups.append(group_obj) + + group_slug = str(group_obj.get("slug") or group_obj.get("name") or "") + skills = group_obj.get("skills") + if not isinstance(skills, list): + continue + + for skill in skills: + if not isinstance(skill, dict): + continue + + name = str(skill.get("name") or "").strip() + if not name: + # If name is missing, it can't be indexed/activated anyway. + continue + + description = str(skill.get("description") or "") + group = str(skill.get("group") or group_slug or "") + + download_path = str(skill.get("download_url") or "") + download_url = ( + urljoin(base_for_join, download_path) + if base_for_join + else download_path + ) + + contents = skill.get("contents") + if not isinstance(contents, dict): + contents = {} + + entries.append( + RemoteSkillEntry( + name=name, + description=description, + group=group, + download_url=download_url, + zip_size_bytes=_safe_int( + skill.get("zip_size_bytes"), default=0 + ), + file_count=_safe_int(skill.get("file_count"), default=0), + has_scripts=_safe_bool( + contents.get("has_scripts"), default=False + ), + has_references=_safe_bool( + contents.get("has_references"), default=False + ), + has_license=_safe_bool( + contents.get("has_license"), default=False + ), + ) + ) + + if not version: + logger.debug("Remote catalog 'version' is missing/empty") + if not base_url: + logger.debug("Remote catalog 'base_url' is missing/empty") + + return RemoteCatalogData( + version=version, + base_url=base_url, + total_skills=total_skills, + groups=groups, + entries=entries, + ) + + except Exception as e: + logger.exception(f"Failed to parse remote catalog JSON: {e}") + return None + + +def fetch_remote_catalog(force_refresh: bool = False) -> Optional[RemoteCatalogData]: + """Fetch the remote skills catalog with caching and offline fallback. + + Cache behavior: + - Cache file: ~/.code_puppy/cache/skills_catalog.json + - TTL: 30 minutes (based on file mtime) + - Offline fallback: if network fetch fails, use cache if present (even if expired) + + Args: + force_refresh: If True, always attempt a network fetch. + + Returns: + Parsed RemoteCatalogData on success, otherwise None. + """ + + cache_fresh = _cache_is_fresh(_CACHE_PATH, _CACHE_TTL_SECONDS) + + # Use fresh cache unless forced. + if not force_refresh and cache_fresh: + logger.info(f"Using fresh remote catalog cache: {_CACHE_PATH}") + cached = _read_cache(_CACHE_PATH) + if cached is None: + logger.warning("Fresh cache exists but could not be read; refetching") + else: + parsed = _parse_catalog(cached) + if parsed is not None: + return parsed + logger.warning("Fresh cache exists but could not be parsed; refetching") + + if force_refresh: + logger.info("Force refresh enabled; fetching remote skills catalog") + elif _CACHE_PATH.exists(): + logger.info( + "Cache is missing or stale; fetching remote skills catalog " + f"(cache_path={_CACHE_PATH}, fresh={cache_fresh})" + ) + else: + logger.info("No cache present; fetching remote skills catalog") + + remote_raw = _fetch_remote_json(SKILLS_JSON_URL) + if remote_raw is not None: + logger.info("Fetched remote skills catalog successfully") + _write_cache(_CACHE_PATH, remote_raw) + parsed = _parse_catalog(remote_raw) + if parsed is not None: + return parsed + logger.warning("Remote catalog fetched but failed to parse") + + # Offline fallback: use cache even if expired. + if _CACHE_PATH.exists(): + logger.warning( + "Remote fetch failed; falling back to cached skills catalog " + f"(even if expired): {_CACHE_PATH}" + ) + cached = _read_cache(_CACHE_PATH) + if cached is None: + return None + return _parse_catalog(cached) + + logger.error("Remote fetch failed and no cache is available") + return None diff --git a/code_puppy/plugins/agent_skills/skill_catalog.py b/code_puppy/plugins/agent_skills/skill_catalog.py new file mode 100644 index 00000000..c615d87c --- /dev/null +++ b/code_puppy/plugins/agent_skills/skill_catalog.py @@ -0,0 +1,257 @@ +"""Remote-backed skill catalog adapter. + +This module provides a stable public interface for the rest of the codebase. +Historically, code_puppy used a local static catalog. We now source skills from +`remote_catalog.fetch_remote_catalog()` while keeping the same access patterns. + +Public API: + from code_puppy.plugins.agent_skills.skill_catalog import ( + SkillCatalog, + SkillCatalogEntry, + _format_display_name, + catalog, + ) + +If the remote catalog can't be fetched (and there's no cache), the catalog is +empty by default (and we log a warning). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +from code_puppy.plugins.agent_skills.remote_catalog import fetch_remote_catalog + +logger = logging.getLogger(__name__) + + +_ACRONYMS = { + "ai", + "api", + "aws", + "cli", + "cpu", + "csv", + "db", + "dns", + "gpu", + "html", + "http", + "https", + "id", + "json", + "jwt", + "k8s", + "llm", + "ml", + "mvp", + "oauth", + "pdf", + "psql", + "qa", + "rest", + "rpc", + "sdk", + "sql", + "ssh", + "ssl", + "tls", + "tsv", + "ui", + "url", + "utc", + "uuid", + "xml", + "yaml", + "yml", +} + + +def _format_display_name(skill_id: str) -> str: + """Format a human-readable display name from a skill id. + + Examples: + data-exploration -> Data Exploration + pdf -> PDF + + This is intentionally simple and predictable. + """ + + cleaned = (skill_id or "").strip() + if not cleaned: + return "" + + parts = [p for p in cleaned.replace("_", "-").split("-") if p] + formatted: list[str] = [] + + for part in parts: + lower = part.lower() + if lower in _ACRONYMS: + formatted.append(lower.upper()) + else: + # Keep existing capitalization for words like "Nextflow" if provided, + # otherwise just Title Case. + formatted.append(part[:1].upper() + part[1:].lower()) + + return " ".join(formatted) + + +@dataclass(frozen=True, slots=True) +class SkillCatalogEntry: + """Catalog entry for a skill. + + Fields are designed to match the historical local catalog interface while + including remote-only fields (download_url, zip_size_bytes). + """ + + id: str + name: str + display_name: str + description: str + category: str + tags: List[str] = field(default_factory=list) + source_path: Optional[Path] = None + has_scripts: bool = False + has_references: bool = False + file_count: int = 0 + download_url: str = "" + zip_size_bytes: int = 0 + + +class SkillCatalog: + """Remote skill catalog. + + This class is a simple in-memory index over remote catalog entries. + """ + + def __init__(self) -> None: + """Initialize the skill catalog with empty indices.""" + + self._entries: list[SkillCatalogEntry] = [] + self._by_id: dict[str, SkillCatalogEntry] = {} + self._by_category: dict[str, list[SkillCatalogEntry]] = {} + + try: + remote = fetch_remote_catalog() + except Exception as e: + # fetch_remote_catalog should already be defensive, but let's be extra safe. + logger.warning(f"Failed to fetch remote catalog: {e}") + remote = None + + if remote is None: + logger.warning( + "Remote skill catalog unavailable (no network and no cache). " + "Catalog will be empty." + ) + return + + entries: list[SkillCatalogEntry] = [] + + for remote_entry in remote.entries: + skill_id = remote_entry.name + entry = SkillCatalogEntry( + id=skill_id, + name=remote_entry.name, + display_name=_format_display_name(remote_entry.name), + description=remote_entry.description, + category=remote_entry.group, + tags=[], + source_path=None, + has_scripts=remote_entry.has_scripts, + has_references=remote_entry.has_references, + file_count=remote_entry.file_count, + download_url=remote_entry.download_url, + zip_size_bytes=remote_entry.zip_size_bytes, + ) + entries.append(entry) + + self._rebuild_indices(entries) + + logger.info( + f"Loaded remote skill catalog: {len(self._entries)} skills in " + f"{len(self._by_category)} categories" + ) + + def _rebuild_indices(self, entries: list[SkillCatalogEntry]) -> None: + """Rebuild internal lookup indices from the loaded entries.""" + + self._entries = list(entries) + self._by_id = {} + self._by_category = {} + + for entry in self._entries: + # Last one wins if duplicates somehow exist. + self._by_id[entry.id] = entry + + cat_key = (entry.category or "").casefold() + self._by_category.setdefault(cat_key, []).append(entry) + + # Keep category lists stable and predictable. + for cat_entries in self._by_category.values(): + cat_entries.sort(key=lambda e: e.display_name.casefold()) + + self._entries.sort(key=lambda e: e.display_name.casefold()) + + def list_categories(self) -> List[str]: + """List all categories.""" + + categories = {e.category for e in self._entries if e.category} + return sorted(categories, key=lambda c: c.casefold()) + + def get_by_category(self, category: str) -> List[SkillCatalogEntry]: + """Return all entries in a category (case-insensitive).""" + + if not category: + return [] + return list(self._by_category.get(category.casefold(), [])) + + def search(self, query: str) -> List[SkillCatalogEntry]: + """Search by substring over id/name/display_name/description/tags/category.""" + + q = (query or "").strip().casefold() + if not q: + return self.get_all() + + results: list[SkillCatalogEntry] = [] + for entry in self._entries: + haystacks = [ + entry.id, + entry.name, + entry.display_name, + entry.description, + entry.category, + " ".join(entry.tags), + ] + + if any(q in (h or "").casefold() for h in haystacks): + results.append(entry) + + return results + + def get_by_id(self, skill_id: str) -> Optional[SkillCatalogEntry]: + """Get a skill entry by id (case-sensitive exact match).""" + + if not skill_id: + return None + return self._by_id.get(skill_id) + + def get_all(self) -> List[SkillCatalogEntry]: + """Return all entries.""" + + return list(self._entries) + + +# Singleton instance used by the rest of the codebase. +# NOTE: This must never crash import-time. +catalog = SkillCatalog() + + +__all__ = [ + "SkillCatalog", + "SkillCatalogEntry", + "_format_display_name", + "catalog", +] diff --git a/code_puppy/plugins/customizable_commands/register_callbacks.py b/code_puppy/plugins/customizable_commands/register_callbacks.py index bee3d67e..c5ae31e7 100644 --- a/code_puppy/plugins/customizable_commands/register_callbacks.py +++ b/code_puppy/plugins/customizable_commands/register_callbacks.py @@ -77,7 +77,9 @@ def _load_markdown_commands() -> None: stripped = line.strip() if stripped and not stripped.startswith("#"): # Truncate long descriptions - description = stripped[:50] + ("..." if len(stripped) > 50 else "") + description = stripped[:50] + ( + "..." if len(stripped) > 50 else "" + ) break # Later directories override earlier ones (project > global) @@ -88,7 +90,6 @@ def _load_markdown_commands() -> None: emit_error(f"Failed to load command from {md_file}: {e}") - def _custom_help() -> List[Tuple[str, str]]: """Return help entries for loaded markdown commands.""" # Reload commands to pick up any changes @@ -148,4 +149,4 @@ def _handle_custom_command(command: str, name: str) -> Optional[Any]: __all__ = ["MarkdownCommandResult"] # Load commands at import time -_load_markdown_commands() \ No newline at end of file +_load_markdown_commands() diff --git a/code_puppy/tools/common.py b/code_puppy/tools/common.py index dfa06a02..de7dde2a 100644 --- a/code_puppy/tools/common.py +++ b/code_puppy/tools/common.py @@ -1407,4 +1407,3 @@ def generate_group_id(tool_name: str, extra_context: str = "") -> str: short_hash = hash_obj.hexdigest()[:8] return f"{tool_name}_{short_hash}" - diff --git a/pyproject.toml b/pyproject.toml index 58c5bed8..a800102b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,6 @@ ignore_no_config = true [tool.hatch.build] packages = ["code_puppy"] -build_data = true [tool.hatch.build.targets.wheel.shared-data] "code_puppy/models.json" = "code_puppy/models.json" diff --git a/tests/agents/test_project_agent_integration.py b/tests/agents/test_project_agent_integration.py index feb88ddf..f50b19c9 100644 --- a/tests/agents/test_project_agent_integration.py +++ b/tests/agents/test_project_agent_integration.py @@ -3,7 +3,6 @@ import json from unittest.mock import patch - from code_puppy.agents.json_agent import discover_json_agents diff --git a/tests/command_line/mcp/test_mcp_utils.py b/tests/command_line/mcp/test_mcp_utils.py index c6a40ca6..0b42d7fc 100644 --- a/tests/command_line/mcp/test_mcp_utils.py +++ b/tests/command_line/mcp/test_mcp_utils.py @@ -16,7 +16,6 @@ ) from code_puppy.mcp_.managed_server import ServerState - # ============================================================================= # Tests for format_state_indicator # ============================================================================= diff --git a/tests/mcp/test_captured_stdio_server.py b/tests/mcp/test_captured_stdio_server.py index cd18d38d..c3c19a0e 100644 --- a/tests/mcp/test_captured_stdio_server.py +++ b/tests/mcp/test_captured_stdio_server.py @@ -18,12 +18,12 @@ import pytest from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from code_puppy.mcp_.blocking_startup import BlockingMCPServerStdio from code_puppy.mcp_.captured_stdio_server import ( CapturedMCPServerStdio, StderrCapture, StderrCollector, ) -from code_puppy.mcp_.blocking_startup import BlockingMCPServerStdio class TestStderrCapture: diff --git a/tests/plugins/test_customizable_commands_callbacks.py b/tests/plugins/test_customizable_commands_callbacks.py index ab57bbc6..cd81c49c 100644 --- a/tests/plugins/test_customizable_commands_callbacks.py +++ b/tests/plugins/test_customizable_commands_callbacks.py @@ -71,7 +71,6 @@ def test_repr_with_empty_content(self): assert repr(result) == "MarkdownCommandResult(0 chars)" - class TestLoadMarkdownCommands: """Test _load_markdown_commands function.""" @@ -558,4 +557,3 @@ def test_commands_dict_exists(self): def test_descriptions_dict_exists(self): """Test that _command_descriptions dict exists after import.""" - from code_puppy.plugins.customizable_commands import register_callbacks \ No newline at end of file diff --git a/tests/scheduler/test_daemon.py b/tests/scheduler/test_daemon.py index 74df8471..55850cb5 100644 --- a/tests/scheduler/test_daemon.py +++ b/tests/scheduler/test_daemon.py @@ -2,11 +2,11 @@ from datetime import datetime, timedelta +from code_puppy.scheduler.config import ScheduledTask from code_puppy.scheduler.daemon import ( parse_interval, should_run_task, ) -from code_puppy.scheduler.config import ScheduledTask class TestParseInterval: diff --git a/tests/test_skill_catalog.py b/tests/test_skill_catalog.py new file mode 100644 index 00000000..a2e9412a --- /dev/null +++ b/tests/test_skill_catalog.py @@ -0,0 +1,232 @@ +"""Tests for the remote-backed skill catalog adapter.""" + +from __future__ import annotations + +import importlib + +import code_puppy.plugins.agent_skills.remote_catalog as rc +from code_puppy.plugins.agent_skills.remote_catalog import ( + RemoteCatalogData, + RemoteSkillEntry, +) + + +def _load_skill_catalog(): + """Import/reload skill_catalog without hitting the network. + + skill_catalog creates a module-level singleton at import time, so we must + patch the remote fetcher before importing. + """ + + def _no_fetch(*args, **kwargs): + """Fixture that prevents real HTTP fetches during tests.""" + + return None + + rc.fetch_remote_catalog = _no_fetch # type: ignore[assignment] + module = importlib.import_module("code_puppy.plugins.agent_skills.skill_catalog") + return importlib.reload(module) + + +def _mk_remote( + *, + base_url: str = "https://example.test", + entries: list[RemoteSkillEntry], +) -> RemoteCatalogData: + """Create a minimal RemoteSkillEntry for testing.""" + + return RemoteCatalogData( + version="1.0.0", + base_url=base_url, + total_skills=len(entries), + groups=[], + entries=entries, + ) + + +def test_format_display_name() -> None: + """Test that skill IDs are formatted into readable display names.""" + + sc_module = _load_skill_catalog() + + assert sc_module._format_display_name("data-exploration") == "Data Exploration" + assert sc_module._format_display_name("pdf") == "PDF" + assert sc_module._format_display_name("sql-queries") == "SQL Queries" + assert sc_module._format_display_name("") == "" + + +def test_catalog_with_remote_data(monkeypatch) -> None: + """Test catalog loads and indexes remote skill data correctly.""" + + sc_module = _load_skill_catalog() + + remote = _mk_remote( + entries=[ + RemoteSkillEntry( + name="data-exploration", + description="Explore data sets", + group="data", + download_url="https://example.test/skills/data-exploration.zip", + zip_size_bytes=1234, + file_count=1, + has_scripts=False, + has_references=False, + has_license=False, + ), + RemoteSkillEntry( + name="sql-queries", + description="Write SQL", + group="data", + download_url="https://example.test/skills/sql-queries.zip", + zip_size_bytes=2345, + file_count=2, + has_scripts=True, + has_references=False, + has_license=True, + ), + RemoteSkillEntry( + name="audit-support", + description="Support SOX audits", + group="finance", + download_url="https://example.test/skills/audit-support.zip", + zip_size_bytes=3456, + file_count=3, + has_scripts=False, + has_references=True, + has_license=False, + ), + ] + ) + + monkeypatch.setattr(sc_module, "fetch_remote_catalog", lambda: remote) + + cat = sc_module.SkillCatalog() + all_entries = cat.get_all() + + assert len(all_entries) == 3 + assert all(isinstance(e, sc_module.SkillCatalogEntry) for e in all_entries) + + # get_by_id + sql_entry = cat.get_by_id("sql-queries") + assert sql_entry is not None + assert sql_entry.display_name == "SQL Queries" + + # list_categories + assert cat.list_categories() == ["data", "finance"] + + # get_by_category + data_entries = cat.get_by_category("data") + assert {e.id for e in data_entries} == {"data-exploration", "sql-queries"} + + # search + assert {e.id for e in cat.search("sql")} == {"sql-queries"} + assert {e.id for e in cat.search("SOX")} == {"audit-support"} + assert {e.id for e in cat.search("finance")} == {"audit-support"} + + +def test_catalog_empty_when_fetch_fails(monkeypatch) -> None: + """Test catalog returns empty results when remote fetch fails.""" + + sc_module = _load_skill_catalog() + + monkeypatch.setattr(sc_module, "fetch_remote_catalog", lambda: None) + + cat = sc_module.SkillCatalog() + assert cat.get_all() == [] + assert cat.list_categories() == [] + assert cat.get_by_id("anything") is None + + +def test_search_case_insensitive(monkeypatch) -> None: + """Test that catalog search is case-insensitive.""" + + sc_module = _load_skill_catalog() + + remote = _mk_remote( + entries=[ + RemoteSkillEntry( + name="pdf-tools", + description="Work with PDF files", + group="office", + download_url="https://example.test/skills/pdf-tools.zip", + zip_size_bytes=1, + file_count=1, + has_scripts=False, + has_references=False, + has_license=False, + ) + ] + ) + monkeypatch.setattr(sc_module, "fetch_remote_catalog", lambda: remote) + + cat = sc_module.SkillCatalog() + assert [e.id for e in cat.search("pdf")] == ["pdf-tools"] + assert [e.id for e in cat.search("PDF")] == ["pdf-tools"] + assert [e.id for e in cat.search("Office")] == ["pdf-tools"] + + +def test_get_by_category_case_insensitive(monkeypatch) -> None: + """Test that category lookup is case-insensitive.""" + + sc_module = _load_skill_catalog() + + remote = _mk_remote( + entries=[ + RemoteSkillEntry( + name="close-management", + description="Month-end close", + group="Finance", + download_url="https://example.test/skills/close-management.zip", + zip_size_bytes=1, + file_count=1, + has_scripts=False, + has_references=False, + has_license=False, + ) + ] + ) + monkeypatch.setattr(sc_module, "fetch_remote_catalog", lambda: remote) + + cat = sc_module.SkillCatalog() + assert [e.id for e in cat.get_by_category("finance")] == ["close-management"] + assert [e.id for e in cat.get_by_category("FINANCE")] == ["close-management"] + + +def test_catalog_entry_fields(monkeypatch) -> None: + """Test that catalog entry fields are correctly populated.""" + + sc_module = _load_skill_catalog() + + remote = _mk_remote( + entries=[ + RemoteSkillEntry( + name="data-exploration", + description="Explore data sets", + group="data", + download_url="https://example.test/skills/data-exploration.zip", + zip_size_bytes=1234, + file_count=42, + has_scripts=True, + has_references=True, + has_license=False, + ) + ] + ) + monkeypatch.setattr(sc_module, "fetch_remote_catalog", lambda: remote) + + cat = sc_module.SkillCatalog() + entry = cat.get_by_id("data-exploration") + assert entry is not None + + assert entry.id == "data-exploration" + assert entry.name == "data-exploration" + assert entry.display_name == "Data Exploration" + assert entry.description == "Explore data sets" + assert entry.category == "data" + assert entry.tags == [] + assert entry.source_path is None + assert entry.has_scripts is True + assert entry.has_references is True + assert entry.file_count == 42 + assert entry.download_url == "https://example.test/skills/data-exploration.zip" + assert entry.zip_size_bytes == 1234 diff --git a/tests/test_skill_downloader.py b/tests/test_skill_downloader.py new file mode 100644 index 00000000..06d3c915 --- /dev/null +++ b/tests/test_skill_downloader.py @@ -0,0 +1,241 @@ +"""Tests for remote skill downloader/installer.""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest + +import code_puppy.plugins.agent_skills.downloader as dl + + +@pytest.fixture(autouse=True) +def _no_refresh(monkeypatch): + """Fixture that prevents catalog refresh during tests.""" + + monkeypatch.setattr(dl, "refresh_skill_cache", lambda: None) + + +def _make_zip(path: Path, files: dict[str, str]) -> None: + """Create a zip file with given file contents.""" + + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for name, content in files.items(): + zf.writestr(name, content) + + +def test_download_and_install_success(tmp_path: Path, monkeypatch) -> None: + """Test successful download and installation of a skill.""" + + skill_name = "test-skill" + skills_dir = tmp_path / "skills" + + src_zip = tmp_path / "src.zip" + _make_zip( + src_zip, + { + "SKILL.md": "---\nname: test-skill\ndescription: hi\n---\n", + "README.txt": "hello", + }, + ) + + def fake_download(url: str, dest: Path) -> bool: + """Fake download function for testing.""" + + dest.write_bytes(src_zip.read_bytes()) + return True + + monkeypatch.setattr(dl, "_download_to_file", fake_download) + + result = dl.download_and_install_skill( + skill_name=skill_name, + download_url="https://example.test/test-skill.zip", + target_dir=skills_dir, + force=False, + ) + + assert result.success is True + assert result.installed_path == skills_dir / skill_name + assert (skills_dir / skill_name / "SKILL.md").is_file() + + +def test_download_fails(tmp_path: Path, monkeypatch) -> None: + """Test graceful handling when download fails.""" + + monkeypatch.setattr(dl, "_download_to_file", lambda url, dest: False) + + result = dl.download_and_install_skill( + skill_name="test-skill", + download_url="https://example.test/test-skill.zip", + target_dir=tmp_path / "skills", + ) + + assert result.success is False + assert "Failed to download" in result.message + + +def test_already_installed_no_force(tmp_path: Path, monkeypatch) -> None: + """Test that already-installed skills are skipped without force.""" + + skill_name = "test-skill" + skills_dir = tmp_path / "skills" + + src_zip = tmp_path / "src.zip" + _make_zip(src_zip, {"SKILL.md": "---\nname: test-skill\ndescription: hi\n---\n"}) + + def fake_download(url: str, dest: Path) -> bool: + """Fake download function for testing.""" + + dest.write_bytes(src_zip.read_bytes()) + return True + + monkeypatch.setattr(dl, "_download_to_file", fake_download) + + first = dl.download_and_install_skill( + skill_name=skill_name, + download_url="https://example.test/test-skill.zip", + target_dir=skills_dir, + ) + assert first.success is True + + second = dl.download_and_install_skill( + skill_name=skill_name, + download_url="https://example.test/test-skill.zip", + target_dir=skills_dir, + force=False, + ) + + assert second.success is False + assert "already installed" in second.message.lower() + + +def test_already_installed_with_force(tmp_path: Path, monkeypatch) -> None: + """Test that force flag replaces already-installed skills.""" + + skill_name = "test-skill" + skills_dir = tmp_path / "skills" + + src_zip_1 = tmp_path / "src1.zip" + _make_zip(src_zip_1, {"SKILL.md": "---\nname: test-skill\ndescription: v1\n---\n"}) + + src_zip_2 = tmp_path / "src2.zip" + _make_zip(src_zip_2, {"SKILL.md": "---\nname: test-skill\ndescription: v2\n---\n"}) + + def fake_download_v1(url: str, dest: Path) -> bool: + """Fake download function for testing.""" + + dest.write_bytes(src_zip_1.read_bytes()) + return True + + monkeypatch.setattr(dl, "_download_to_file", fake_download_v1) + + first = dl.download_and_install_skill( + skill_name=skill_name, + download_url="https://example.test/test-skill.zip", + target_dir=skills_dir, + ) + assert first.success is True + + # Now reinstall with different zip + def fake_download_v2(url: str, dest: Path) -> bool: + """Fake download function for testing.""" + + dest.write_bytes(src_zip_2.read_bytes()) + return True + + monkeypatch.setattr(dl, "_download_to_file", fake_download_v2) + + second = dl.download_and_install_skill( + skill_name=skill_name, + download_url="https://example.test/test-skill.zip", + target_dir=skills_dir, + force=True, + ) + assert second.success is True + + installed = (skills_dir / skill_name / "SKILL.md").read_text(encoding="utf-8") + assert "v2" in installed + + +def test_invalid_zip(tmp_path: Path, monkeypatch) -> None: + """Test handling of corrupted zip archives.""" + + skills_dir = tmp_path / "skills" + garbage = tmp_path / "garbage.zip" + garbage.write_bytes(b"not a zip") + + def fake_download(url: str, dest: Path) -> bool: + """Fake download function for testing.""" + + dest.write_bytes(garbage.read_bytes()) + return True + + monkeypatch.setattr(dl, "_download_to_file", fake_download) + + result = dl.download_and_install_skill( + skill_name="test-skill", + download_url="https://example.test/test-skill.zip", + target_dir=skills_dir, + ) + + assert result.success is False + assert "valid zip" in result.message.lower() + + +def test_missing_skill_md_in_zip(tmp_path: Path, monkeypatch) -> None: + """Test handling of zip archives missing SKILL.md.""" + + skills_dir = tmp_path / "skills" + src_zip = tmp_path / "src.zip" + _make_zip(src_zip, {"README.md": "no skill md"}) + + def fake_download(url: str, dest: Path) -> bool: + """Fake download function for testing.""" + + dest.write_bytes(src_zip.read_bytes()) + return True + + monkeypatch.setattr(dl, "_download_to_file", fake_download) + + result = dl.download_and_install_skill( + skill_name="test-skill", + download_url="https://example.test/test-skill.zip", + target_dir=skills_dir, + ) + + assert result.success is False + assert "missing skill.md" in result.message.lower() + + +def test_zip_with_subdirectory(tmp_path: Path, monkeypatch) -> None: + """Zip contains a single top-level directory; installer should flatten it.""" + + skills_dir = tmp_path / "skills" + src_zip = tmp_path / "src.zip" + + _make_zip( + src_zip, + { + "some-folder/SKILL.md": "---\nname: test-skill\ndescription: hi\n---\n", + "some-folder/foo.txt": "bar", + }, + ) + + def fake_download(url: str, dest: Path) -> bool: + """Fake download function for testing.""" + + dest.write_bytes(src_zip.read_bytes()) + return True + + monkeypatch.setattr(dl, "_download_to_file", fake_download) + + result = dl.download_and_install_skill( + skill_name="test-skill", + download_url="https://example.test/test-skill.zip", + target_dir=skills_dir, + ) + + assert result.success is True + assert (skills_dir / "test-skill" / "SKILL.md").is_file() + assert (skills_dir / "test-skill" / "foo.txt").is_file()