"""SkillsMixin: Enumerate available skills for Claude and Codex agents. Skills are agent-global (not session-specific), loaded from well-known filesystem locations for each agent type. """ import json from pathlib import Path class SkillsMixin: """Mixin for enumerating agent skills for autocomplete.""" def _serve_skills(self, agent: str) -> None: """Serve autocomplete config for an agent type. Args: agent: Agent type ('claude' or 'codex') Response JSON: {trigger: '/' or '$', skills: [{name, description}, ...]} """ if agent == "codex": trigger = "$" skills = self._enumerate_codex_skills() else: # Default to claude trigger = "/" skills = self._enumerate_claude_skills() # Sort alphabetically by name (case-insensitive) skills.sort(key=lambda s: s["name"].lower()) self._send_json(200, {"trigger": trigger, "skills": skills}) def _enumerate_claude_skills(self) -> list[dict]: """Enumerate Claude skills from ~/.claude/skills/. Checks SKILL.md (canonical) first, then falls back to skill.md, prompt.md, README.md for description extraction. Parses YAML frontmatter if present to extract the description field. Returns: List of {name: str, description: str} dicts. Empty list if directory doesn't exist or enumeration fails. """ skills = [] skills_dir = Path.home() / ".claude/skills" if not skills_dir.exists(): return skills for skill_dir in skills_dir.iterdir(): if not skill_dir.is_dir() or skill_dir.name.startswith("."): continue description = "" # Check files in priority order for md_name in ["SKILL.md", "skill.md", "prompt.md", "README.md"]: md_file = skill_dir / md_name if md_file.exists(): try: content = md_file.read_text() description = self._extract_description(content) if description: break except OSError: pass skills.append({ "name": skill_dir.name, "description": description or f"Skill: {skill_dir.name}", }) return skills def _extract_description(self, content: str) -> str: """Extract description from markdown content. Handles YAML frontmatter (looks for 'description:' field) and falls back to first meaningful line after frontmatter. """ lines = content.splitlines() if not lines: return "" # Check for YAML frontmatter frontmatter_end = 0 if lines[0].strip() == "---": for i, line in enumerate(lines[1:], start=1): stripped = line.strip() if stripped == "---": frontmatter_end = i + 1 break # Look for description field in frontmatter if stripped.startswith("description:"): # Extract value after colon desc = stripped[len("description:"):].strip() # Remove quotes if present if desc.startswith('"') and desc.endswith('"'): desc = desc[1:-1] elif desc.startswith("'") and desc.endswith("'"): desc = desc[1:-1] # Handle YAML multi-line indicators (>- or |-) if desc in (">-", "|-", ">", "|", ""): # Multi-line: read the next indented line if i + 1 < len(lines): next_line = lines[i + 1].strip() if next_line and not next_line.startswith("---"): return next_line[:100] elif desc: return desc[:100] # Fall back to first meaningful line after frontmatter for line in lines[frontmatter_end:]: stripped = line.strip() # Skip empty lines, headers, comments, and frontmatter delimiters if stripped and not stripped.startswith("#") and not stripped.startswith("