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(" -