feat: implement tweaks plan - modals, smart shell, spinner, /models, debug log, skills
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>
This commit is contained in:
71
app/services/skills.py
Normal file
71
app/services/skills.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user