This commit is contained in:
Administrator committed 2026-06-28 00:23:01 +08:00
1 parent 65449ffd71
commit d522498cc9
8 files changed
+779 -165

No files matched your search

@@ -4,9 +4,11 @@ import logging
import mimetypes
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -220,6 +222,7 @@ class SkillInstallRequest(BaseModel):
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)")
skill_name: str | None = Field(default=None, description="Optional custom install name. When set, rewrites the archive SKILL.md frontmatter name before install.")
target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
@@ -233,6 +236,32 @@ class SkillInstallResponse(BaseModel):
category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'")
class SkillInstallPreviewRequest(BaseModel):
"""Request model for preflighting a .skill artifact install."""
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
path: str = Field(..., description="Virtual path to the .skill file")
target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
class SkillInstallPreviewResponse(BaseModel):
"""Response model for skill install preflight."""
ok: bool = Field(..., description="Whether the archive could be parsed")
skill_name: str = Field(..., description="Skill name parsed from the archive frontmatter")
description: str = Field(default="", description="Parsed description from the archive frontmatter")
license: str | None = Field(default=None, description="Parsed license from the archive frontmatter")
allowed_tools: list[str] = Field(default_factory=list, description="Allowed tools declared in the archive")
can_install_as_is: bool = Field(default=True, description="Whether the current user may install the archive without renaming")
owned_by_me: bool = Field(default=False, description="True when the existing conflicting skill is already owned by the caller")
owned_by_other: bool = Field(default=False, description="True when another user already owns the conflicting custom skill")
builtin_conflict: bool = Field(default=False, description="True when the parsed name collides with a built-in/public skill")
owner_username: str | None = Field(default=None, description="Friendly owner username for owned_by_other conflicts")
owner_user_id: str | None = Field(default=None, description="Owner user id for custom conflicts")
message: str = Field(default="", description="Human-readable install guidance for the UI")
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings from validation")
class SkillUploadValidateResponse(BaseModel):
"""Response model for the pre-install validation endpoint."""
@@ -1040,6 +1069,162 @@ async def install_skill_upload(
tmp_path.unlink(missing_ok=True)
async def _preview_install_target(
*,
request: Request,
skill_store: SkillStore,
skill_name: str,
target_category: SkillCategory,
) -> tuple[bool, bool, bool, bool, str | None, str | None, str]:
"""Return installability / ownership info for a parsed skill name."""
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
existing = await skill_store.get_any(skill_name)
owner_user_id = existing.get("owner_user_id") if existing else None
owner_username: str | None = None
if owner_user_id:
try:
attached = await _attach_owner_usernames(
[SkillResponse(name=skill_name, description="", license=None, category=SkillCategory.CUSTOM, owner_user_id=owner_user_id)]
)
owner_username = attached[0].owner_username if attached else None
except Exception:
owner_username = None
builtin_conflict = bool(existing and owner_user_id is None)
owned_by_me = bool(existing and owner_user_id == user_id)
owned_by_other = bool(existing and owner_user_id not in (None, user_id))
if target_category == SkillCategory.PUBLIC and not is_admin:
return False, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, "Only admin users can install skills to the public directory"
if builtin_conflict:
return False, owned_by_me, owned_by_other, True, owner_user_id, owner_username, f"Skill '{skill_name}' already exists as a built-in/public skill. Please install it under a different name."
if owned_by_other:
who = owner_username or "another user"
return False, owned_by_me, True, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' has already been installed by {who}. Please choose a different name."
if owned_by_me:
return False, True, False, builtin_conflict, owner_user_id, owner_username, f"You have already installed skill '{skill_name}'. You can keep using it or install this copy under a different name."
return True, False, False, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' can be installed."
async def _build_renamed_skill_archive(
archive_path: str | Path,
*,
new_name: str,
require_skill_suffix: bool,
) -> Path:
"""Create a temporary archive whose SKILL.md frontmatter name is rewritten."""
from deerflow.skills.installer import resolve_skill_dir_from_archive, safe_extract_skill_archive
SkillStorage.validate_skill_name(new_name)
tmp_root = Path(tempfile.mkdtemp(prefix="skill-install-rename-"))
try:
path = Path(archive_path)
if require_skill_suffix and path.suffix != ".skill":
raise ValueError("File must have .skill extension")
with zipfile.ZipFile(path, "r") as zf:
safe_extract_skill_archive(zf, tmp_root)
skill_dir = resolve_skill_dir_from_archive(tmp_root)
skill_md = skill_dir / SKILL_MD_FILE
skill_md.write_text(
_rewrite_skill_frontmatter_name(skill_md.read_text(encoding="utf-8"), new_name),
encoding="utf-8",
)
renamed_archive = tmp_root / f"{new_name}.skill"
with zipfile.ZipFile(renamed_archive, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in sorted(tmp_root.rglob("*")):
if not path.is_file() or path == renamed_archive:
continue
zf.write(path, path.relative_to(tmp_root))
return renamed_archive
except Exception:
shutil.rmtree(tmp_root, ignore_errors=True)
raise
async def _install_archive_with_optional_name(
*,
storage: SkillStorage,
archive_path: str | Path,
requested_name: str | None,
target_category: SkillCategory,
overwrite: bool = False,
require_skill_suffix: bool = True,
) -> SkillInstallResponse:
normalized_requested = SkillStorage.validate_skill_name(requested_name) if requested_name else None
temp_root: Path | None = None
install_source = Path(archive_path)
if normalized_requested:
renamed_archive = await _build_renamed_skill_archive(
archive_path,
new_name=normalized_requested,
require_skill_suffix=require_skill_suffix,
)
temp_root = renamed_archive.parent
install_source = renamed_archive
try:
result = await storage.ainstall_skill_from_archive(
install_source,
overwrite=overwrite,
require_skill_suffix=require_skill_suffix,
target_category=target_category,
)
return SkillInstallResponse(**result)
finally:
if temp_root is not None:
shutil.rmtree(temp_root, ignore_errors=True)
@router.post(
"/skills/install-preview",
response_model=SkillInstallPreviewResponse,
summary="Preview Skill Install",
description="Parse a .skill artifact from a thread and return ownership/conflict info before install.",
)
async def preview_skill_install(
body: SkillInstallPreviewRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillInstallPreviewResponse:
try:
skill_file_path = await aresolve_thread_virtual_path(body.thread_id, body.path)
validation = get_or_new_skill_storage(app_config=config).validate_skill_archive(skill_file_path, require_skill_suffix=False)
skill_name = str(validation.get("skill_name") or "")
can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, message = await _preview_install_target(
request=request,
skill_store=skill_store,
skill_name=skill_name,
target_category=body.target_category,
)
return SkillInstallPreviewResponse(
ok=True,
skill_name=skill_name,
description=str(validation.get("description") or ""),
license=validation.get("license"),
allowed_tools=[str(item) for item in (validation.get("allowed_tools") or [])],
can_install_as_is=can_install_as_is,
owned_by_me=owned_by_me,
owned_by_other=owned_by_other,
builtin_conflict=builtin_conflict,
owner_username=owner_username,
owner_user_id=owner_user_id,
message=message,
warnings=[str(item) for item in (validation.get("warnings") or [])],
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to preview skill install: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to preview skill install: {e}")
@router.post(
"/skills/install",
response_model=SkillInstallResponse,
@@ -1056,25 +1241,46 @@ async def install_skill(
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
target_category = body.target_category
requested_name = SkillStorage.validate_skill_name(body.skill_name) if body.skill_name else None
if target_category == SkillCategory.PUBLIC and not is_admin:
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
skill_file_path = await aresolve_thread_virtual_path(body.thread_id, body.path)
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
storage = get_or_new_skill_storage(app_config=config)
validation = storage.validate_skill_archive(skill_file_path, require_skill_suffix=False)
preview_name = requested_name or str(validation.get("skill_name") or "")
can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, _owner_user_id, _owner_username, message = await _preview_install_target(
request=request,
skill_store=skill_store,
skill_name=preview_name,
target_category=target_category,
)
if not can_install_as_is:
if requested_name and requested_name != validation.get("skill_name") and not owned_by_other and not builtin_conflict:
pass
else:
raise HTTPException(status_code=409, detail=message)
result = await _install_archive_with_optional_name(
storage=storage,
archive_path=skill_file_path,
requested_name=requested_name,
target_category=target_category,
require_skill_suffix=False,
)
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(result["skill_name"])
mark_uploaded(result.skill_name)
except Exception:
pass
if target_category == SkillCategory.PUBLIC:
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": None, "published": True}
{"name": result.skill_name, "owner_user_id": None, "published": True}
)
else:
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
{"name": result.skill_name, "owner_user_id": user_id, "published": False}
)
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result)
return result
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except SkillAlreadyExistsError as e: