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 <noreply@anthropic.com>
This commit is contained in:
hetaoBackend
2026-01-31 22:10:05 +08:00
parent c76481d8f7
commit 5834b15af7
4 changed files with 370 additions and 28 deletions

View File

@@ -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)}")

View File

@@ -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 (
<Artifact className={cn(className)}>
<ArtifactHeader className="px-2">
@@ -155,13 +181,13 @@ export function ArtifactFileDetail({
<div className="flex items-center gap-2">
<ArtifactActions>
{!isWriteFile && filepath.endsWith(".skill") && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
icon={PackageIcon}
label={t.common.install}
tooltip={t.common.openInNewWindow}
/>
</a>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
tooltip={t.common.install}
disabled={isInstalling}
onClick={handleInstallSkill}
/>
)}
{!isWriteFile && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">

View File

@@ -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<string | null>(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 (
<ul className={cn("flex w-full flex-col gap-4", className)}>
{files.map((file) => (
@@ -49,20 +82,18 @@ export function ArtifactFileList({
</CardDescription>
<CardAction>
{file.endsWith(".skill") && (
<a
href={urlOfArtifact({
filepath: file,
threadId: threadId,
download: true,
})}
target="_blank"
onClick={(e) => e.stopPropagation()}
<Button
variant="ghost"
disabled={installingFile === file}
onClick={(e) => handleInstallSkill(e, file)}
>
<Button variant="ghost">
{installingFile === file ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<PackageIcon className="size-4" />
{t.common.install}
</Button>
</a>
)}
{t.common.install}
</Button>
)}
<a
href={urlOfArtifact({

View File

@@ -24,12 +24,39 @@ export async function enableSkill(skillName: string, enabled: boolean) {
return response.json();
}
export async function installSkill(skillName: string) {
const response = await fetch(
`${getBackendBaseURL()}/api/skills/${skillName}/install`,
{
method: "POST",
export interface InstallSkillRequest {
thread_id: string;
path: string;
}
export interface InstallSkillResponse {
success: boolean;
skill_name: string;
message: string;
}
export async function installSkill(
request: InstallSkillRequest,
): Promise<InstallSkillResponse> {
const response = await fetch(`${getBackendBaseURL()}/api/skills/install`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify(request),
});
if (!response.ok) {
// Handle HTTP error responses (4xx, 5xx)
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
return {
success: false,
skill_name: "",
message: errorMessage,
};
}
return response.json();
}