feat(dashboard): add skill autocomplete server-side enumeration and client wiring
- Add SkillsMixin with _enumerate_claude_skills and _enumerate_codex_skills - Claude: reads ~/.claude/skills/, parses YAML frontmatter for descriptions - Codex: reads curated cache + ~/.codex/skills/ user directory - Add /api/skills?agent= endpoint to HttpMixin - Add fetchSkills() API helper in dashboard - Wire autocomplete config through Modal -> SessionCard -> SimpleInput - Add getTriggerInfo() for detecting trigger at valid positions Closes: bd-3q1, bd-sv1, bd-3eu, bd-g9t, bd-30p, bd-1ba, bd-2n7, bd-3s3 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
179
amc_server/mixins/skills.py
Normal file
179
amc_server/mixins/skills.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""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("<!--") and stripped != "---":
|
||||
return stripped[:100]
|
||||
|
||||
return ""
|
||||
|
||||
def _enumerate_codex_skills(self) -> list[dict]:
|
||||
"""Enumerate Codex skills from cache and user directory.
|
||||
|
||||
Sources:
|
||||
- ~/.codex/vendor_imports/skills-curated-cache.json (curated)
|
||||
- ~/.codex/skills/*/ (user-installed)
|
||||
|
||||
Note: No deduplication — if curated and user skills share a name,
|
||||
both appear in the list (per plan Known Limitations).
|
||||
|
||||
Returns:
|
||||
List of {name: str, description: str} dicts.
|
||||
Empty list if no skills found.
|
||||
"""
|
||||
skills = []
|
||||
|
||||
# 1. Curated skills from cache
|
||||
cache_file = Path.home() / ".codex/vendor_imports/skills-curated-cache.json"
|
||||
if cache_file.exists():
|
||||
try:
|
||||
data = json.loads(cache_file.read_text())
|
||||
for skill in data.get("skills", []):
|
||||
# Use 'id' preferentially, fall back to 'name'
|
||||
name = skill.get("id") or skill.get("name", "")
|
||||
# Use 'shortDescription' preferentially, fall back to 'description'
|
||||
desc = skill.get("shortDescription") or skill.get("description", "")
|
||||
if name:
|
||||
skills.append({
|
||||
"name": name,
|
||||
"description": desc[:100] if desc else f"Skill: {name}",
|
||||
})
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Continue without curated skills on parse error
|
||||
pass
|
||||
|
||||
# 2. User-installed skills
|
||||
user_skills_dir = Path.home() / ".codex/skills"
|
||||
if user_skills_dir.exists():
|
||||
for skill_dir in user_skills_dir.iterdir():
|
||||
if not skill_dir.is_dir() or skill_dir.name.startswith("."):
|
||||
continue
|
||||
|
||||
description = ""
|
||||
# Check SKILL.md for description
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if skill_md.exists():
|
||||
try:
|
||||
content = skill_md.read_text()
|
||||
description = self._extract_description(content)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
skills.append({
|
||||
"name": skill_dir.name,
|
||||
"description": description or f"User skill: {skill_dir.name}",
|
||||
})
|
||||
|
||||
return skills
|
||||
Reference in New Issue
Block a user