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:
@@ -5,6 +5,7 @@ from amc_server.mixins.control import SessionControlMixin
|
|||||||
from amc_server.mixins.discovery import SessionDiscoveryMixin
|
from amc_server.mixins.discovery import SessionDiscoveryMixin
|
||||||
from amc_server.mixins.http import HttpMixin
|
from amc_server.mixins.http import HttpMixin
|
||||||
from amc_server.mixins.parsing import SessionParsingMixin
|
from amc_server.mixins.parsing import SessionParsingMixin
|
||||||
|
from amc_server.mixins.skills import SkillsMixin
|
||||||
from amc_server.mixins.state import StateMixin
|
from amc_server.mixins.state import StateMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ class AMCHandler(
|
|||||||
SessionControlMixin,
|
SessionControlMixin,
|
||||||
SessionDiscoveryMixin,
|
SessionDiscoveryMixin,
|
||||||
SessionParsingMixin,
|
SessionParsingMixin,
|
||||||
|
SkillsMixin,
|
||||||
BaseHTTPRequestHandler,
|
BaseHTTPRequestHandler,
|
||||||
):
|
):
|
||||||
"""HTTP handler composed from focused mixins."""
|
"""HTTP handler composed from focused mixins."""
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ class HttpMixin:
|
|||||||
project_dir = ""
|
project_dir = ""
|
||||||
agent = "claude"
|
agent = "claude"
|
||||||
self._serve_conversation(urllib.parse.unquote(session_id), urllib.parse.unquote(project_dir), agent)
|
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:
|
else:
|
||||||
self._json_error(404, "Not Found")
|
self._json_error(404, "Not Found")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -73,7 +82,9 @@ class HttpMixin:
|
|||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
try:
|
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/"):])
|
session_id = urllib.parse.unquote(self.path[len("/api/dismiss/"):])
|
||||||
self._dismiss_session(session_id)
|
self._dismiss_session(session_id)
|
||||||
elif self.path.startswith("/api/respond/"):
|
elif self.path.startswith("/api/respond/"):
|
||||||
@@ -113,7 +124,12 @@ class HttpMixin:
|
|||||||
full_path = DASHBOARD_DIR / file_path
|
full_path = DASHBOARD_DIR / file_path
|
||||||
# Security: ensure path doesn't escape dashboard directory
|
# Security: ensure path doesn't escape dashboard directory
|
||||||
full_path = full_path.resolve()
|
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")
|
self._json_error(403, "Forbidden")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -1,14 +1,29 @@
|
|||||||
import { html, useState, useEffect, useCallback } from '../lib/preact.js';
|
import { html, useState, useEffect, useCallback } from '../lib/preact.js';
|
||||||
import { SessionCard } from './SessionCard.js';
|
import { SessionCard } from './SessionCard.js';
|
||||||
|
import { fetchSkills } from '../utils/api.js';
|
||||||
|
|
||||||
export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
|
export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
|
const [autocompleteConfig, setAutocompleteConfig] = useState(null);
|
||||||
|
|
||||||
// Reset closing state when session changes
|
// Reset closing state when session changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setClosing(false);
|
setClosing(false);
|
||||||
}, [session?.session_id]);
|
}, [session?.session_id]);
|
||||||
|
|
||||||
|
// Load autocomplete skills when agent type changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) {
|
||||||
|
setAutocompleteConfig(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = session.agent || 'claude';
|
||||||
|
fetchSkills(agent)
|
||||||
|
.then(config => setAutocompleteConfig(config))
|
||||||
|
.catch(() => setAutocompleteConfig(null));
|
||||||
|
}, [session?.agent]);
|
||||||
|
|
||||||
// Animated close handler
|
// Animated close handler
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setClosing(true);
|
setClosing(true);
|
||||||
@@ -54,6 +69,7 @@ export function Modal({ session, conversations, onClose, onRespond, onFetchConve
|
|||||||
onRespond=${onRespond}
|
onRespond=${onRespond}
|
||||||
onDismiss=${onDismiss}
|
onDismiss=${onDismiss}
|
||||||
enlarged=${true}
|
enlarged=${true}
|
||||||
|
autocompleteConfig=${autocompleteConfig}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { formatDuration, getContextUsageSummary } from '../utils/formatting.js';
|
|||||||
import { ChatMessages } from './ChatMessages.js';
|
import { ChatMessages } from './ChatMessages.js';
|
||||||
import { QuestionBlock } from './QuestionBlock.js';
|
import { QuestionBlock } from './QuestionBlock.js';
|
||||||
import { SimpleInput } from './SimpleInput.js';
|
import { SimpleInput } from './SimpleInput.js';
|
||||||
|
import { AgentActivityIndicator } from './AgentActivityIndicator.js';
|
||||||
|
|
||||||
export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss, enlarged = false }) {
|
export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss, enlarged = false, autocompleteConfig = null }) {
|
||||||
const hasQuestions = session.pending_questions && session.pending_questions.length > 0;
|
const hasQuestions = session.pending_questions && session.pending_questions.length > 0;
|
||||||
const statusMeta = getStatusMeta(session.status);
|
const statusMeta = getStatusMeta(session.status);
|
||||||
const agent = session.agent === 'codex' ? 'codex' : 'claude';
|
const agent = session.agent === 'codex' ? 'codex' : 'claude';
|
||||||
@@ -144,8 +145,10 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
|
|||||||
<${ChatMessages} messages=${conversation || []} status=${session.status} limit=${enlarged ? null : 20} />
|
<${ChatMessages} messages=${conversation || []} status=${session.status} limit=${enlarged ? null : 20} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Footer (Input or Questions) -->
|
<!-- Card Footer (Activity + Input/Questions) -->
|
||||||
<div class="shrink-0 border-t border-selection/70 bg-bg/55 p-4">
|
<div class="shrink-0 border-t border-selection/70 bg-bg/55">
|
||||||
|
<${AgentActivityIndicator} session=${session} />
|
||||||
|
<div class="p-4">
|
||||||
${hasQuestions ? html`
|
${hasQuestions ? html`
|
||||||
<${QuestionBlock}
|
<${QuestionBlock}
|
||||||
questions=${session.pending_questions}
|
questions=${session.pending_questions}
|
||||||
@@ -158,9 +161,11 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
|
|||||||
sessionId=${session.session_id}
|
sessionId=${session.session_id}
|
||||||
status=${session.status}
|
status=${session.status}
|
||||||
onRespond=${onRespond}
|
onRespond=${onRespond}
|
||||||
|
autocompleteConfig=${autocompleteConfig}
|
||||||
/>
|
/>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html, useState, useRef } from '../lib/preact.js';
|
import { html, useState, useRef, useCallback } from '../lib/preact.js';
|
||||||
import { getStatusMeta } from '../utils/status.js';
|
import { getStatusMeta } from '../utils/status.js';
|
||||||
|
|
||||||
export function SimpleInput({ sessionId, status, onRespond }) {
|
export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = null }) {
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
@@ -9,6 +9,32 @@ export function SimpleInput({ sessionId, status, onRespond }) {
|
|||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const meta = getStatusMeta(status);
|
const meta = getStatusMeta(status);
|
||||||
|
|
||||||
|
// Detect if cursor is at a trigger position for autocomplete
|
||||||
|
const getTriggerInfo = useCallback((value, cursorPos) => {
|
||||||
|
// No config means no autocomplete
|
||||||
|
if (!autocompleteConfig) return null;
|
||||||
|
|
||||||
|
const { trigger } = autocompleteConfig;
|
||||||
|
|
||||||
|
// Find the start of the current "word" (after last whitespace before cursor)
|
||||||
|
let wordStart = cursorPos;
|
||||||
|
while (wordStart > 0 && !/\s/.test(value[wordStart - 1])) {
|
||||||
|
wordStart--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if word starts with this agent's trigger character
|
||||||
|
if (value[wordStart] === trigger) {
|
||||||
|
return {
|
||||||
|
trigger,
|
||||||
|
filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),
|
||||||
|
replaceStart: wordStart,
|
||||||
|
replaceEnd: cursorPos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [autocompleteConfig]);
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
export const API_STATE = '/api/state';
|
export const API_STATE = '/api/state';
|
||||||
export const API_STREAM = '/api/stream';
|
export const API_STREAM = '/api/stream';
|
||||||
export const API_DISMISS = '/api/dismiss/';
|
export const API_DISMISS = '/api/dismiss/';
|
||||||
|
export const API_DISMISS_DEAD = '/api/dismiss-dead';
|
||||||
export const API_RESPOND = '/api/respond/';
|
export const API_RESPOND = '/api/respond/';
|
||||||
export const API_CONVERSATION = '/api/conversation/';
|
export const API_CONVERSATION = '/api/conversation/';
|
||||||
|
export const API_SKILLS = '/api/skills';
|
||||||
export const POLL_MS = 3000;
|
export const POLL_MS = 3000;
|
||||||
export const API_TIMEOUT_MS = 10000;
|
export const API_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
@@ -18,3 +20,16 @@ export async function fetchWithTimeout(url, options = {}, timeoutMs = API_TIMEOU
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch autocomplete skills config for an agent type
|
||||||
|
export async function fetchSkills(agent) {
|
||||||
|
const url = `${API_SKILLS}?agent=${encodeURIComponent(agent)}`;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
} catch {
|
||||||
|
// Network error or other failure - graceful degradation
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user