Phase 1: Permission modal dialog, session resume modal, HistoryInput with up/down arrow cycling, remove "You:" echo from chat log, LLM client cleanup on unmount. Phase 2: Smart shell auto-approve using allowed/denied command lists from ToolsConfig, animated thinking spinner with live token count in status bar. Phase 3: /models slash command (list + switch), CLI directory positional argument, JSONL debug logger with rotation. Phase 4: Skills system with SkillsManager, load_skill LLM tool, /skills listing, skill invocation via slash commands, system prompt integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
72 lines
2.5 KiB
Python
72 lines
2.5 KiB
Python
"""Skills manager — scans for and loads skill markdown files."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from app.models.config import SkillsConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Skill(BaseModel):
|
|
"""Metadata for a discovered skill file."""
|
|
|
|
name: str
|
|
description: str
|
|
path: Path
|
|
|
|
|
|
class SkillsManager:
|
|
"""Discovers, indexes, and loads skill files from configured directories."""
|
|
|
|
def __init__(self, config: SkillsConfig, workspace_root: Path) -> None:
|
|
self._config = config
|
|
self._workspace = workspace_root
|
|
self._skills: dict[str, Skill] = {}
|
|
self._scan()
|
|
|
|
def _scan(self) -> None:
|
|
"""Scan configured directories for .md skill files."""
|
|
for skill_dir in self._config.directories:
|
|
resolved = (self._workspace / skill_dir) if not skill_dir.is_absolute() else skill_dir
|
|
if not resolved.is_dir():
|
|
logger.debug("Skills directory does not exist: %s", resolved)
|
|
continue
|
|
for md in sorted(resolved.glob("*.md")):
|
|
name = md.stem
|
|
desc = self._extract_description(md)
|
|
self._skills[name] = Skill(name=name, description=desc, path=md)
|
|
logger.debug("Discovered skill: %s (%s)", name, desc)
|
|
|
|
@staticmethod
|
|
def _extract_description(path: Path) -> str:
|
|
"""Extract the first non-blank, non-heading line as the description."""
|
|
for line in path.read_text().splitlines():
|
|
stripped = line.strip()
|
|
if stripped and not stripped.startswith("#"):
|
|
return stripped
|
|
return "(no description)"
|
|
|
|
def list_skills(self) -> list[Skill]:
|
|
"""Return all discovered skills."""
|
|
return list(self._skills.values())
|
|
|
|
def load_skill(self, name: str) -> str | None:
|
|
"""Load the full content of a skill by name. Returns None if not found."""
|
|
skill = self._skills.get(name)
|
|
return skill.path.read_text() if skill else None
|
|
|
|
def get_system_prompt_snippet(self) -> str:
|
|
"""Generate a snippet for the system prompt listing available skills."""
|
|
if not self._skills:
|
|
return ""
|
|
lines = ["\nAvailable skills (invoke with /skill-name):"]
|
|
for s in self._skills.values():
|
|
lines.append(f" - /{s.name}: {s.description}")
|
|
lines.append("To use a skill's full instructions, call the load_skill tool.")
|
|
return "\n".join(lines)
|