import json import logging import shutil from pathlib import Path import httpx from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from app.gateway.config import get_gateway_config from app.gateway.path_utils import resolve_thread_virtual_path from app.gateway.skill_yaml_importer import materialize_skill_tree, parse_skill_yaml_spec from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config from deerflow.skills import Skill, load_skills from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["skills"]) class SkillResponse(BaseModel): """Response model for skill information.""" name: str = Field(..., description="Name of the skill") description: str = Field(..., description="Description of what the skill does") license: str | None = Field(None, description="License information") category: str = Field(..., description="Category of the skill (public or custom)") enabled: bool = Field(default=True, description="Whether this skill is enabled") class SkillsListResponse(BaseModel): """Response model for listing all skills.""" skills: list[SkillResponse] class SkillUpdateRequest(BaseModel): """Request model for updating a skill.""" 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") class RemoteSkillBootstrapRequest(BaseModel): """Request model for bootstrapping skill files from remote content API.""" thread_id: str = Field(..., description="Thread ID used for user-data path binding") content_ids: list[int] = Field( ..., min_length=1, description="Remote content ID sequence (maps from frontend query param skill_id)", ) language_type: int = Field(default=0, description="Language type for remote API request body") target_dir: str = Field( default="/mnt/user-data/uploads/skill", description="Virtual base directory where each skill-{id} subdirectory is created", ) clear_target: bool = Field( default=True, description="Whether to clear target directory before writing parsed files", ) class RemoteSkillBootstrapResponse(BaseModel): """Response model for remote bootstrap endpoint.""" success: bool = Field(..., description="Whether bootstrap succeeded") target_dir: str = Field(..., description="Virtual base target directory") content_ids: list[int] = Field(..., description="Bootstrapped content IDs") created_directories: int = Field(..., description="Number of created directories") created_files: int = Field(..., description="Number of created files") sandbox_id: str | None = Field(default=None, description="Acquired sandbox ID (null when sandbox is not acquired)") message: str = Field(..., description="Operation result message") def _skill_to_response(skill: Skill) -> SkillResponse: """Convert a Skill object to a SkillResponse.""" return SkillResponse( name=skill.name, description=skill.description, license=skill.license, category=skill.category, enabled=skill.enabled, ) @router.get( "/skills", response_model=SkillsListResponse, summary="List All Skills", description="Retrieve a list of all available skills from both public and custom directories.", ) async def list_skills() -> SkillsListResponse: try: skills = load_skills(enabled_only=False) return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills]) except Exception as e: logger.error(f"Failed to load skills: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}") @router.get( "/skills/{skill_name}", response_model=SkillResponse, summary="Get Skill Details", description="Retrieve detailed information about a specific skill by its name.", ) async def get_skill(skill_name: str) -> SkillResponse: try: skills = load_skills(enabled_only=False) skill = next((s for s in skills if s.name == skill_name), None) if skill is None: raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") return _skill_to_response(skill) except HTTPException: raise except Exception as e: logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}") @router.put( "/skills/{skill_name}", response_model=SkillResponse, summary="Update Skill", description="Update a skill's enabled status by modifying the extensions_config.json file.", ) async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillResponse: try: skills = load_skills(enabled_only=False) skill = next((s for s in skills if s.name == skill_name), None) if skill is None: raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") config_path = ExtensionsConfig.resolve_config_path() if config_path is None: config_path = Path.cwd().parent / "extensions_config.json" logger.info(f"No existing extensions config found. Creating new config at: {config_path}") extensions_config = get_extensions_config() extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled) config_data = { "mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()}, "skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()}, } with open(config_path, "w", encoding="utf-8") as f: json.dump(config_data, f, indent=2) logger.info(f"Skills configuration updated and saved to: {config_path}") reload_extensions_config() skills = load_skills(enabled_only=False) updated_skill = next((s for s in skills if s.name == skill_name), None) if updated_skill is None: raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update") logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}") return _skill_to_response(updated_skill) except HTTPException: raise 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: try: skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path) result = install_skill_from_archive(skill_file_path) return SkillInstallResponse(**result) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except SkillAlreadyExistsError as e: raise HTTPException(status_code=409, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) 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)}") @router.post( "/skills/bootstrap-remote", response_model=RemoteSkillBootstrapResponse, summary="Bootstrap Skill Files From Remote API", description=( "Fetch YAML text from configured remote API by content_ids/language_type and " "materialize files into skill-{id} directories under /mnt/user-data/uploads/skill " "before first thread submit." ), ) async def bootstrap_skill_from_remote(request: RemoteSkillBootstrapRequest) -> RemoteSkillBootstrapResponse: """Initialize thread skill directory from remote YAML content service.""" try: cfg = get_gateway_config() api_url = cfg.skill_content_api_url created_directories_total = 0 created_files_total = 0 base_target_path = resolve_thread_virtual_path(request.thread_id, request.target_dir) if request.clear_target and base_target_path.exists(): if base_target_path.is_dir(): shutil.rmtree(base_target_path) else: base_target_path.unlink() base_target_path.mkdir(parents=True, exist_ok=True) async with httpx.AsyncClient(timeout=20.0) as client: for content_id in request.content_ids: payload = { "contentId": content_id, "languageType": request.language_type, } response = await client.post(api_url, json=payload) if response.status_code >= 400: raise HTTPException( status_code=502, detail=( "Remote skill content API failed with HTTP " f"{response.status_code} for content_id={content_id}" ), ) try: response_json = response.json() except ValueError as e: raise HTTPException(status_code=502, detail=f"Remote API did not return valid JSON: {e}") from e status = response_json.get("status") if status != 1000: raise HTTPException( status_code=502, detail=( "Remote API returned non-success status: " f"{status}, message: {response_json.get('message')}, content_id={content_id}" ), ) yaml_text = response_json.get("data") if not isinstance(yaml_text, str) or not yaml_text.strip(): raise HTTPException( status_code=502, detail=f"Remote API returned empty or invalid YAML content for content_id={content_id}", ) target_dir = f"{request.target_dir.rstrip('/')}/skill-{content_id}" target_path = resolve_thread_virtual_path(request.thread_id, target_dir) parsed = parse_skill_yaml_spec(yaml_text) materialize_skill_tree(parsed, target_path, clear_target=False) created_directories_total += len(parsed.directories) created_files_total += len(parsed.files) logger.info( "Bootstrapped remote skill YAML for thread %s (content_id=%s, language_type=%s) to %s: dirs=%d files=%d", request.thread_id, content_id, request.language_type, target_dir, len(parsed.directories), len(parsed.files), ) return RemoteSkillBootstrapResponse( success=True, target_dir=request.target_dir, content_ids=request.content_ids, created_directories=created_directories_total, created_files=created_files_total, sandbox_id=None, message=( f"Bootstrapped {created_files_total} files and {created_directories_total} directories " f"for {len(request.content_ids)} skills under '{request.target_dir}'" ), ) except HTTPException: raise except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to bootstrap skill from remote API: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to bootstrap skill from remote API: {str(e)}")