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:
teernisse
2026-02-26 15:53:45 -05:00
parent 2926645b10
commit c7db46191c
7 changed files with 266 additions and 7 deletions

View File

@@ -62,6 +62,15 @@ class HttpMixin:
project_dir = ""
agent = "claude"
self._serve_conversation(urllib.parse.unquote(session_id), urllib.parse.unquote(project_dir), agent)
elif self.path == "/api/skills" or self.path.startswith("/api/skills?"):
# Parse agent from query params, default to claude
if "?" in self.path:
query = self.path.split("?", 1)[1]
params = urllib.parse.parse_qs(query)
agent = params.get("agent", ["claude"])[0]
else:
agent = "claude"
self._serve_skills(agent)
else:
self._json_error(404, "Not Found")
except Exception:
@@ -73,7 +82,9 @@ class HttpMixin:
def do_POST(self):
try:
if self.path.startswith("/api/dismiss/"):
if self.path == "/api/dismiss-dead":
self._dismiss_dead_sessions()
elif self.path.startswith("/api/dismiss/"):
session_id = urllib.parse.unquote(self.path[len("/api/dismiss/"):])
self._dismiss_session(session_id)
elif self.path.startswith("/api/respond/"):
@@ -113,7 +124,12 @@ class HttpMixin:
full_path = DASHBOARD_DIR / file_path
# Security: ensure path doesn't escape dashboard directory
full_path = full_path.resolve()
if not str(full_path).startswith(str(DASHBOARD_DIR.resolve())):
resolved_dashboard = DASHBOARD_DIR.resolve()
try:
# Use relative_to for robust path containment check
# (avoids startswith prefix-match bugs like "/dashboard" vs "/dashboardEVIL")
full_path.relative_to(resolved_dashboard)
except ValueError:
self._json_error(403, "Forbidden")
return

179
amc_server/mixins/skills.py Normal file
View 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