From 5834b15af729d0b0318c5d59bdbf45934aebe31e Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Sat, 31 Jan 2026 22:10:05 +0800 Subject: [PATCH] feat: add skill installation API endpoint Add POST /api/skills/install endpoint to install .skill files from thread's user-data directory. The endpoint extracts the ZIP archive, validates SKILL.md frontmatter, and installs to skills/custom/. Frontend Install buttons now call the API instead of downloading. Co-Authored-By: Claude Opus 4.5 --- backend/src/gateway/routers/skills.py | 258 ++++++++++++++++++ .../artifacts/artifact-file-detail.tsx | 42 ++- .../artifacts/artifact-file-list.tsx | 59 +++- frontend/src/core/skills/api.ts | 39 ++- 4 files changed, 370 insertions(+), 28 deletions(-) diff --git a/backend/src/gateway/routers/skills.py b/backend/src/gateway/routers/skills.py index 4a47074..67bca69 100644 --- a/backend/src/gateway/routers/skills.py +++ b/backend/src/gateway/routers/skills.py @@ -1,12 +1,19 @@ import json import logging +import os +import re +import shutil +import tempfile +import zipfile from pathlib import Path +import yaml from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from src.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config from src.skills import Skill, load_skills +from src.skills.loader import get_skills_root_path logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["skills"]) @@ -34,6 +41,141 @@ class SkillUpdateRequest(BaseModel): enabled: bool = Field(..., description="Whether to enable or disable the skill") +class SkillInstallRequest(BaseModel): + """Request model for installing a skill from a .skill file.""" + + thread_id: str = Field(..., description="The thread ID where the .skill file is located") + path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)") + + +class SkillInstallResponse(BaseModel): + """Response model for skill installation.""" + + success: bool = Field(..., description="Whether the installation was successful") + skill_name: str = Field(..., description="Name of the installed skill") + message: str = Field(..., description="Installation result message") + + +# Base directory for thread data (relative to backend/) +THREAD_DATA_BASE_DIR = ".deer-flow/threads" + +# Virtual path prefix used in sandbox environments (without leading slash for URL path matching) +VIRTUAL_PATH_PREFIX = "mnt/user-data" + +# Allowed properties in SKILL.md frontmatter +ALLOWED_FRONTMATTER_PROPERTIES = {"name", "description", "license", "allowed-tools", "metadata"} + + +def _resolve_skill_file_path(thread_id: str, virtual_path: str) -> Path: + """Resolve a virtual skill file path to the actual filesystem path. + + Args: + thread_id: The thread ID. + virtual_path: The virtual path (e.g., mnt/user-data/outputs/my-skill.skill). + + Returns: + The resolved filesystem path. + + Raises: + HTTPException: If the path is invalid or outside allowed directories. + """ + # Remove leading slash if present + virtual_path = virtual_path.lstrip("/") + + # Validate and remove virtual path prefix + if not virtual_path.startswith(VIRTUAL_PATH_PREFIX): + raise HTTPException(status_code=400, detail=f"Path must start with /{VIRTUAL_PATH_PREFIX}") + relative_path = virtual_path[len(VIRTUAL_PATH_PREFIX) :].lstrip("/") + + # Build the actual path + base_dir = Path(os.getcwd()) / THREAD_DATA_BASE_DIR / thread_id / "user-data" + actual_path = base_dir / relative_path + + # Security check: ensure the path is within the thread's user-data directory + try: + actual_path = actual_path.resolve() + base_dir_resolved = base_dir.resolve() + if not str(actual_path).startswith(str(base_dir_resolved)): + raise HTTPException(status_code=403, detail="Access denied: path traversal detected") + except (ValueError, RuntimeError): + raise HTTPException(status_code=400, detail="Invalid path") + + return actual_path + + +def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]: + """Validate a skill directory's SKILL.md frontmatter. + + Args: + skill_dir: Path to the skill directory containing SKILL.md. + + Returns: + Tuple of (is_valid, message, skill_name). + """ + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + return False, "SKILL.md not found", None + + content = skill_md.read_text() + if not content.startswith("---"): + return False, "No YAML frontmatter found", None + + # Extract frontmatter + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format", None + + frontmatter_text = match.group(1) + + # Parse YAML frontmatter + try: + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary", None + except yaml.YAMLError as e: + return False, f"Invalid YAML in frontmatter: {e}", None + + # Check for unexpected properties + unexpected_keys = set(frontmatter.keys()) - ALLOWED_FRONTMATTER_PROPERTIES + if unexpected_keys: + return False, f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}", None + + # Check required fields + if "name" not in frontmatter: + return False, "Missing 'name' in frontmatter", None + if "description" not in frontmatter: + return False, "Missing 'description' in frontmatter", None + + # Validate name + name = frontmatter.get("name", "") + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}", None + name = name.strip() + if not name: + return False, "Name cannot be empty", None + + # Check naming convention (hyphen-case: lowercase with hyphens) + if not re.match(r"^[a-z0-9-]+$", name): + return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", None + if name.startswith("-") or name.endswith("-") or "--" in name: + return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", None + if len(name) > 64: + return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters.", None + + # Validate description + description = frontmatter.get("description", "") + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}", None + description = description.strip() + if description: + if "<" in description or ">" in description: + return False, "Description cannot contain angle brackets (< or >)", None + if len(description) > 1024: + return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", None + + return True, "Skill is valid!", name + + def _skill_to_response(skill: Skill) -> SkillResponse: """Convert a Skill object to a SkillResponse.""" return SkillResponse( @@ -225,3 +367,119 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillRes except Exception as e: logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}") + + +@router.post( + "/skills/install", + response_model=SkillInstallResponse, + summary="Install Skill", + description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.", +) +async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse: + """Install a skill from a .skill file. + + The .skill file is a ZIP archive containing a skill directory with SKILL.md + and optional resources (scripts, references, assets). + + Args: + request: The install request containing thread_id and virtual path to .skill file. + + Returns: + Installation result with skill name and status message. + + Raises: + HTTPException: + - 400 if path is invalid or file is not a valid .skill file + - 403 if access denied (path traversal detected) + - 404 if file not found + - 409 if skill already exists + - 500 if installation fails + + Example Request: + ```json + { + "thread_id": "abc123-def456", + "path": "/mnt/user-data/outputs/my-skill.skill" + } + ``` + + Example Response: + ```json + { + "success": true, + "skill_name": "my-skill", + "message": "Skill 'my-skill' installed successfully" + } + ``` + """ + try: + # Resolve the virtual path to actual file path + skill_file_path = _resolve_skill_file_path(request.thread_id, request.path) + + # Check if file exists + if not skill_file_path.exists(): + raise HTTPException(status_code=404, detail=f"Skill file not found: {request.path}") + + # Check if it's a file + if not skill_file_path.is_file(): + raise HTTPException(status_code=400, detail=f"Path is not a file: {request.path}") + + # Check file extension + if not skill_file_path.suffix == ".skill": + raise HTTPException(status_code=400, detail="File must have .skill extension") + + # Verify it's a valid ZIP file + if not zipfile.is_zipfile(skill_file_path): + raise HTTPException(status_code=400, detail="File is not a valid ZIP archive") + + # Get the custom skills directory + skills_root = get_skills_root_path() + custom_skills_dir = skills_root / "custom" + + # Create custom directory if it doesn't exist + custom_skills_dir.mkdir(parents=True, exist_ok=True) + + # Extract to a temporary directory first for validation + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Extract the .skill file + with zipfile.ZipFile(skill_file_path, "r") as zip_ref: + zip_ref.extractall(temp_path) + + # Find the skill directory (should be the only top-level directory) + extracted_items = list(temp_path.iterdir()) + if len(extracted_items) == 0: + raise HTTPException(status_code=400, detail="Skill archive is empty") + + # Handle both cases: single directory or files directly in root + if len(extracted_items) == 1 and extracted_items[0].is_dir(): + skill_dir = extracted_items[0] + else: + # Files are directly in the archive root + skill_dir = temp_path + + # Validate the skill + is_valid, message, skill_name = _validate_skill_frontmatter(skill_dir) + if not is_valid: + raise HTTPException(status_code=400, detail=f"Invalid skill: {message}") + + if not skill_name: + raise HTTPException(status_code=400, detail="Could not determine skill name") + + # Check if skill already exists + target_dir = custom_skills_dir / skill_name + if target_dir.exists(): + raise HTTPException(status_code=409, detail=f"Skill '{skill_name}' already exists. Please remove it first or use a different name.") + + # Move the skill directory to the custom skills directory + shutil.copytree(skill_dir, target_dir) + + logger.info(f"Skill '{skill_name}' installed successfully to {target_dir}") + return SkillInstallResponse(success=True, skill_name=skill_name, message=f"Skill '{skill_name}' installed successfully") + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to install skill: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}") diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index b2b9f59..56e96be 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -4,12 +4,13 @@ import { DownloadIcon, ExternalLinkIcon, EyeIcon, + LoaderIcon, PackageIcon, SquareArrowOutUpRightIcon, XIcon, } from "lucide-react"; import * as React from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -46,6 +47,7 @@ import { type Citation, } from "@/core/citations"; import { useI18n } from "@/core/i18n/hooks"; +import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; import { checkCodeFile, getFileName } from "@/core/utils/files"; import { cn } from "@/lib/utils"; @@ -99,6 +101,8 @@ export function ArtifactFileDetail({ }, [content, language]); const [viewMode, setViewMode] = useState<"code" | "preview">("code"); + const [isInstalling, setIsInstalling] = useState(false); + useEffect(() => { if (previewable) { setViewMode("preview"); @@ -106,6 +110,28 @@ export function ArtifactFileDetail({ setViewMode("code"); } }, [previewable]); + + const handleInstallSkill = useCallback(async () => { + if (isInstalling) return; + + setIsInstalling(true); + try { + const result = await installSkill({ + thread_id: threadId, + path: filepath, + }); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message || "Failed to install skill"); + } + } catch (error) { + console.error("Failed to install skill:", error); + toast.error("Failed to install skill"); + } finally { + setIsInstalling(false); + } + }, [threadId, filepath, isInstalling]); return ( @@ -155,13 +181,13 @@ export function ArtifactFileDetail({
{!isWriteFile && filepath.endsWith(".skill") && ( - - - + )} {!isWriteFile && ( diff --git a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx index fbd1b78..876881c 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx @@ -1,5 +1,6 @@ -import { DownloadIcon, PackageIcon } from "lucide-react"; -import { useCallback } from "react"; +import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -11,6 +12,7 @@ import { } from "@/components/ui/card"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; +import { installSkill } from "@/core/skills/api"; import { getFileExtensionDisplayName, getFileName } from "@/core/utils/files"; import { cn } from "@/lib/utils"; @@ -27,6 +29,8 @@ export function ArtifactFileList({ }) { const { t } = useI18n(); const { select: selectArtifact, setOpen } = useArtifacts(); + const [installingFile, setInstallingFile] = useState(null); + const handleClick = useCallback( (filepath: string) => { selectArtifact(filepath); @@ -34,6 +38,35 @@ export function ArtifactFileList({ }, [selectArtifact, setOpen], ); + + const handleInstallSkill = useCallback( + async (e: React.MouseEvent, filepath: string) => { + e.stopPropagation(); + e.preventDefault(); + + if (installingFile) return; + + setInstallingFile(filepath); + try { + const result = await installSkill({ + thread_id: threadId, + path: filepath, + }); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message || "Failed to install skill"); + } + } catch (error) { + console.error("Failed to install skill:", error); + toast.error("Failed to install skill"); + } finally { + setInstallingFile(null); + } + }, + [threadId, installingFile], + ); + return (