diff --git a/amc_server/handler.py b/amc_server/handler.py index 76dccb5..77c1234 100644 --- a/amc_server/handler.py +++ b/amc_server/handler.py @@ -5,6 +5,7 @@ from amc_server.mixins.control import SessionControlMixin from amc_server.mixins.discovery import SessionDiscoveryMixin from amc_server.mixins.http import HttpMixin from amc_server.mixins.parsing import SessionParsingMixin +from amc_server.mixins.skills import SkillsMixin from amc_server.mixins.state import StateMixin @@ -15,6 +16,7 @@ class AMCHandler( SessionControlMixin, SessionDiscoveryMixin, SessionParsingMixin, + SkillsMixin, BaseHTTPRequestHandler, ): """HTTP handler composed from focused mixins.""" diff --git a/amc_server/mixins/http.py b/amc_server/mixins/http.py index e184809..f7e207a 100644 --- a/amc_server/mixins/http.py +++ b/amc_server/mixins/http.py @@ -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 diff --git a/amc_server/mixins/skills.py b/amc_server/mixins/skills.py new file mode 100644 index 0000000..dc58bd4 --- /dev/null +++ b/amc_server/mixins/skills.py @@ -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(" -
+ +
+ <${AgentActivityIndicator} session=${session} /> +
${hasQuestions ? html` <${QuestionBlock} questions=${session.pending_questions} @@ -158,8 +161,10 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio sessionId=${session.session_id} status=${session.status} onRespond=${onRespond} + autocompleteConfig=${autocompleteConfig} /> `} +
`; diff --git a/dashboard/components/SimpleInput.js b/dashboard/components/SimpleInput.js index 6943f9f..de0bb37 100644 --- a/dashboard/components/SimpleInput.js +++ b/dashboard/components/SimpleInput.js @@ -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'; -export function SimpleInput({ sessionId, status, onRespond }) { +export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = null }) { const [text, setText] = useState(''); const [focused, setFocused] = useState(false); const [sending, setSending] = useState(false); @@ -9,6 +9,32 @@ export function SimpleInput({ sessionId, status, onRespond }) { const textareaRef = useRef(null); 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) => { e.preventDefault(); e.stopPropagation(); diff --git a/dashboard/utils/api.js b/dashboard/utils/api.js index 2965b39..0e31666 100644 --- a/dashboard/utils/api.js +++ b/dashboard/utils/api.js @@ -2,8 +2,10 @@ export const API_STATE = '/api/state'; export const API_STREAM = '/api/stream'; export const API_DISMISS = '/api/dismiss/'; +export const API_DISMISS_DEAD = '/api/dismiss-dead'; export const API_RESPOND = '/api/respond/'; export const API_CONVERSATION = '/api/conversation/'; +export const API_SKILLS = '/api/skills'; export const POLL_MS = 3000; export const API_TIMEOUT_MS = 10000; @@ -18,3 +20,16 @@ export async function fetchWithTimeout(url, options = {}, timeoutMs = API_TIMEOU 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; + } +}