ji
This commit is contained in:
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:
|
||||
|
||||
Reference in new issue
Block a user