import json import logging import os import re import shutil import tempfile import zipfile from datetime import datetime from typing import Any, Callable, Dict, List, Optional from fastapi import HTTPException, UploadFile from sqlmodel import Session, select from clients.edge.errors import log_edge_failure from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT from models.bot import BotInstance from models.skill import BotSkillInstall, SkillMarketItem from services.platform_settings_service import get_platform_settings_snapshot EdgeStateContextResolver = Callable[[str], Optional[tuple[Any, Optional[str], str]]] class SkillService: def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance: bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") return bot def _workspace_root(self, bot_id: str) -> str: return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace")) def _skills_root(self, bot_id: str) -> str: return os.path.join(self._workspace_root(bot_id), "skills") def _skill_market_root(self) -> str: return os.path.abspath(os.path.join(DATA_ROOT, "skills")) def _is_valid_top_level_skill_name(self, name: str) -> bool: text = str(name or "").strip() if not text: return False if "/" in text or "\\" in text: return False if text in {".", ".."}: return False return True def _read_skill_description(self, entry_path: str) -> str: candidates: List[str] = [] if os.path.isdir(entry_path): candidates = [ os.path.join(entry_path, "SKILL.md"), os.path.join(entry_path, "skill.md"), os.path.join(entry_path, "README.md"), os.path.join(entry_path, "readme.md"), ] elif entry_path.lower().endswith(".md"): candidates = [entry_path] for candidate in candidates: if not os.path.isfile(candidate): continue try: with open(candidate, "r", encoding="utf-8") as file: for line in file: text = line.strip() if text and not text.startswith("#"): return text[:240] except Exception: continue return "" def list_workspace_skills( self, *, bot_id: str, resolve_edge_state_context: EdgeStateContextResolver, logger: logging.Logger, ) -> List[Dict[str, Any]]: edge_context = resolve_edge_state_context(bot_id) if edge_context is not None: client, workspace_root, node_id = edge_context try: payload = client.list_tree( bot_id=bot_id, path="skills", recursive=False, workspace_root=workspace_root, ) except Exception as exc: log_edge_failure( logger, key=f"skills-list:{node_id}:{bot_id}", exc=exc, message=f"Failed to list skills from edge workspace for bot_id={bot_id}", ) return [] rows: List[Dict[str, Any]] = [] for entry in list(payload.get("entries") or []): if not isinstance(entry, dict): continue name = str(entry.get("name") or "").strip() if not name or name.startswith("."): continue if not self._is_valid_top_level_skill_name(name): continue entry_type = str(entry.get("type") or "").strip().lower() if entry_type not in {"dir", "file"}: continue mtime = str(entry.get("mtime") or "").strip() or (datetime.utcnow().isoformat() + "Z") size = entry.get("size") rows.append( { "id": name, "name": name, "type": entry_type, "path": f"skills/{name}", "size": int(size) if isinstance(size, (int, float)) and entry_type == "file" else None, "mtime": mtime, "description": "", } ) rows.sort(key=lambda row: (row.get("type") != "dir", str(row.get("name") or "").lower())) return rows root = self._skills_root(bot_id) if not os.path.isdir(root): return [] rows: List[Dict[str, Any]] = [] names = sorted(os.listdir(root), key=lambda name: (not os.path.isdir(os.path.join(root, name)), name.lower())) for name in names: if not name or name.startswith("."): continue if not self._is_valid_top_level_skill_name(name): continue abs_path = os.path.join(root, name) if not os.path.exists(abs_path): continue stat = os.stat(abs_path) rows.append( { "id": name, "name": name, "type": "dir" if os.path.isdir(abs_path) else "file", "path": f"skills/{name}", "size": stat.st_size if os.path.isfile(abs_path) else None, "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", "description": self._read_skill_description(abs_path), } ) return rows def list_workspace_skills_for_bot( self, *, session: Session, bot_id: str, resolve_edge_state_context: EdgeStateContextResolver, logger: logging.Logger, ) -> List[Dict[str, Any]]: self._require_bot(session=session, bot_id=bot_id) return self.list_workspace_skills( bot_id=bot_id, resolve_edge_state_context=resolve_edge_state_context, logger=logger, ) def _parse_json_string_list(self, raw: Any) -> List[str]: if not raw: return [] try: data = json.loads(str(raw)) except Exception: return [] if not isinstance(data, list): return [] rows: List[str] = [] for item in data: text = str(item or "").strip() if text and text not in rows: rows.append(text) return rows def _is_ignored_skill_zip_top_level(self, name: str) -> bool: text = str(name or "").strip() if not text: return True lowered = text.lower() if lowered == "__macosx": return True if text.startswith("."): return True return False def _read_description_from_text(self, raw: str) -> str: for line in str(raw or "").splitlines(): text = line.strip() if text and not text.startswith("#"): return text[:240] return "" def _extract_skill_zip_summary(self, zip_path: str) -> Dict[str, Any]: entry_names: List[str] = [] description = "" with zipfile.ZipFile(zip_path) as archive: members = archive.infolist() file_members = [member for member in members if not member.is_dir()] for member in file_members: raw_name = str(member.filename or "").replace("\\", "/").lstrip("/") if not raw_name: continue first = raw_name.split("/", 1)[0].strip() if self._is_ignored_skill_zip_top_level(first): continue if self._is_valid_top_level_skill_name(first) and first not in entry_names: entry_names.append(first) candidates = sorted( [ str(member.filename or "").replace("\\", "/").lstrip("/") for member in file_members if str(member.filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower() in {"skill.md", "readme.md"} ], key=lambda value: (value.count("/"), value.lower()), ) for candidate in candidates: try: with archive.open(candidate, "r") as file: preview = file.read(4096).decode("utf-8", errors="ignore") description = self._read_description_from_text(preview) if description: break except Exception: continue return { "entry_names": entry_names, "description": description, } def _sanitize_skill_market_key(self, raw: Any) -> str: value = str(raw or "").strip().lower() value = re.sub(r"[^a-z0-9._-]+", "-", value) value = re.sub(r"-{2,}", "-", value).strip("._-") return value[:120] def _sanitize_zip_filename(self, raw: Any) -> str: filename = os.path.basename(str(raw or "").strip()) if not filename: return "" filename = filename.replace("\\", "/").rsplit("/", 1)[-1] stem, ext = os.path.splitext(filename) safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("._-") if not safe_stem: safe_stem = "skill-package" safe_ext = ".zip" if ext.lower() == ".zip" else "" return f"{safe_stem[:180]}{safe_ext}" def _resolve_unique_skill_market_key(self, session: Session, preferred_key: str, exclude_id: Optional[int] = None) -> str: base_key = self._sanitize_skill_market_key(preferred_key) or "skill" candidate = base_key counter = 2 while True: stmt = select(SkillMarketItem).where(SkillMarketItem.skill_key == candidate) rows = session.exec(stmt).all() conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None) if not conflict: return candidate candidate = f"{base_key}-{counter}" counter += 1 def _resolve_unique_skill_market_zip_filename( self, session: Session, filename: str, *, exclude_filename: Optional[str] = None, exclude_id: Optional[int] = None, ) -> str: root = self._skill_market_root() os.makedirs(root, exist_ok=True) safe_name = self._sanitize_zip_filename(filename) if not safe_name.lower().endswith(".zip"): raise HTTPException(status_code=400, detail="Only .zip skill package is supported") candidate = safe_name stem, ext = os.path.splitext(safe_name) counter = 2 while True: file_conflict = os.path.exists(os.path.join(root, candidate)) and candidate != str(exclude_filename or "").strip() rows = session.exec(select(SkillMarketItem).where(SkillMarketItem.zip_filename == candidate)).all() db_conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None) if not file_conflict and not db_conflict: return candidate candidate = f"{stem}-{counter}{ext}" counter += 1 async def _store_skill_market_zip_upload( self, session: Session, upload: UploadFile, *, exclude_filename: Optional[str] = None, exclude_id: Optional[int] = None, ) -> Dict[str, Any]: root = self._skill_market_root() os.makedirs(root, exist_ok=True) incoming_name = self._sanitize_zip_filename(upload.filename or "") if not incoming_name.lower().endswith(".zip"): raise HTTPException(status_code=400, detail="Only .zip skill package is supported") target_filename = self._resolve_unique_skill_market_zip_filename( session, incoming_name, exclude_filename=exclude_filename, exclude_id=exclude_id, ) max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024 total_size = 0 tmp_path: Optional[str] = None try: with tempfile.NamedTemporaryFile(prefix=".skill_market_", suffix=".zip", dir=root, delete=False) as tmp_zip: tmp_path = tmp_zip.name while True: chunk = await upload.read(1024 * 1024) if not chunk: break total_size += len(chunk) if total_size > max_bytes: raise HTTPException( status_code=413, detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)", ) tmp_zip.write(chunk) if total_size == 0: raise HTTPException(status_code=400, detail="Zip package is empty") summary = self._extract_skill_zip_summary(tmp_path) if not summary["entry_names"]: raise HTTPException(status_code=400, detail="Zip package has no valid skill entries") final_path = os.path.join(root, target_filename) os.replace(tmp_path, final_path) tmp_path = None return { "zip_filename": target_filename, "zip_size_bytes": total_size, "entry_names": summary["entry_names"], "description": summary["description"], } except zipfile.BadZipFile as exc: raise HTTPException(status_code=400, detail="Invalid zip file") from exc finally: await upload.close() if tmp_path and os.path.exists(tmp_path): os.remove(tmp_path) def serialize_skill_market_item( self, item: SkillMarketItem, *, install_count: int = 0, install_row: Optional[BotSkillInstall] = None, workspace_installed: Optional[bool] = None, installed_entries: Optional[List[str]] = None, ) -> Dict[str, Any]: zip_path = os.path.join(self._skill_market_root(), str(item.zip_filename or "")) entry_names = self._parse_json_string_list(item.entry_names_json) payload = { "id": item.id, "skill_key": item.skill_key, "display_name": item.display_name or item.skill_key, "description": item.description or "", "zip_filename": item.zip_filename, "zip_size_bytes": int(item.zip_size_bytes or 0), "entry_names": entry_names, "entry_count": len(entry_names), "zip_exists": os.path.isfile(zip_path), "install_count": int(install_count or 0), "created_at": item.created_at.isoformat() + "Z" if item.created_at else None, "updated_at": item.updated_at.isoformat() + "Z" if item.updated_at else None, } if install_row is not None: resolved_entries = installed_entries if installed_entries is not None else self._parse_json_string_list(install_row.installed_entries_json) resolved_installed = workspace_installed if workspace_installed is not None else install_row.status == "INSTALLED" payload.update( { "installed": resolved_installed, "install_status": install_row.status, "installed_at": install_row.installed_at.isoformat() + "Z" if install_row.installed_at else None, "installed_entries": resolved_entries, "install_error": install_row.last_error, } ) return payload def list_market_items(self, *, session: Session) -> List[Dict[str, Any]]: items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all() installs = session.exec(select(BotSkillInstall)).all() install_count_by_skill: Dict[int, int] = {} for row in installs: skill_id = int(row.skill_market_item_id or 0) if skill_id <= 0 or row.status != "INSTALLED": continue install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1 return [ self.serialize_skill_market_item(item, install_count=install_count_by_skill.get(int(item.id or 0), 0)) for item in items ] async def create_market_item( self, *, session: Session, skill_key: str, display_name: str, description: str, file: UploadFile, ) -> Dict[str, Any]: upload_meta = await self._store_skill_market_zip_upload(session, file) try: preferred_key = skill_key or display_name or os.path.splitext(upload_meta["zip_filename"])[0] next_key = self._resolve_unique_skill_market_key(session, preferred_key) item = SkillMarketItem( skill_key=next_key, display_name=str(display_name or next_key).strip() or next_key, description=str(description or upload_meta["description"] or "").strip(), zip_filename=upload_meta["zip_filename"], zip_size_bytes=int(upload_meta["zip_size_bytes"] or 0), entry_names_json=json.dumps(upload_meta["entry_names"], ensure_ascii=False), ) session.add(item) session.commit() session.refresh(item) return self.serialize_skill_market_item(item, install_count=0) except Exception: target_path = os.path.join(self._skill_market_root(), upload_meta["zip_filename"]) if os.path.exists(target_path): os.remove(target_path) raise async def update_market_item( self, *, session: Session, skill_id: int, skill_key: str, display_name: str, description: str, file: Optional[UploadFile], ) -> Dict[str, Any]: item = session.get(SkillMarketItem, skill_id) if not item: raise HTTPException(status_code=404, detail="Skill market item not found") old_filename = str(item.zip_filename or "").strip() upload_meta: Optional[Dict[str, Any]] = None if file is not None: upload_meta = await self._store_skill_market_zip_upload( session, file, exclude_filename=old_filename or None, exclude_id=item.id, ) next_key = self._resolve_unique_skill_market_key( session, skill_key or item.skill_key or display_name or os.path.splitext(upload_meta["zip_filename"] if upload_meta else old_filename)[0], exclude_id=item.id, ) item.skill_key = next_key item.display_name = str(display_name or item.display_name or next_key).strip() or next_key item.description = str(description or (upload_meta["description"] if upload_meta else item.description) or "").strip() item.updated_at = datetime.utcnow() if upload_meta: item.zip_filename = upload_meta["zip_filename"] item.zip_size_bytes = int(upload_meta["zip_size_bytes"] or 0) item.entry_names_json = json.dumps(upload_meta["entry_names"], ensure_ascii=False) session.add(item) session.commit() session.refresh(item) if upload_meta and old_filename and old_filename != upload_meta["zip_filename"]: old_path = os.path.join(self._skill_market_root(), old_filename) if os.path.exists(old_path): os.remove(old_path) installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all() install_count = sum(1 for row in installs if row.status == "INSTALLED") return self.serialize_skill_market_item(item, install_count=install_count) def delete_market_item(self, *, session: Session, skill_id: int) -> Dict[str, Any]: item = session.get(SkillMarketItem, skill_id) if not item: raise HTTPException(status_code=404, detail="Skill market item not found") zip_filename = str(item.zip_filename or "").strip() installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all() for row in installs: session.delete(row) session.delete(item) session.commit() if zip_filename: zip_path = os.path.join(self._skill_market_root(), zip_filename) if os.path.exists(zip_path): os.remove(zip_path) return {"status": "deleted", "id": skill_id} def list_bot_market_items( self, *, bot_id: str, session: Session, resolve_edge_state_context: EdgeStateContextResolver, logger: logging.Logger, ) -> List[Dict[str, Any]]: items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all() install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all() install_lookup = {int(row.skill_market_item_id): row for row in install_rows} all_install_rows = session.exec(select(BotSkillInstall)).all() install_count_by_skill: Dict[int, int] = {} for row in all_install_rows: skill_id = int(row.skill_market_item_id or 0) if skill_id <= 0 or row.status != "INSTALLED": continue install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1 workspace_skill_names = { str(row.get("name") or "").strip() for row in self.list_workspace_skills(bot_id=bot_id, resolve_edge_state_context=resolve_edge_state_context, logger=logger) } return [ self.serialize_skill_market_item( item, install_count=install_count_by_skill.get(int(item.id or 0), 0), install_row=install_lookup.get(int(item.id or 0)), workspace_installed=( None if install_lookup.get(int(item.id or 0)) is None else ( install_lookup[int(item.id or 0)].status == "INSTALLED" and all( name in workspace_skill_names for name in self._parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json) ) ) ), installed_entries=( None if install_lookup.get(int(item.id or 0)) is None else self._parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json) ), ) for item in items ] def list_bot_market_items_for_bot( self, *, session: Session, bot_id: str, resolve_edge_state_context: EdgeStateContextResolver, logger: logging.Logger, ) -> List[Dict[str, Any]]: self._require_bot(session=session, bot_id=bot_id) return self.list_bot_market_items( bot_id=bot_id, session=session, resolve_edge_state_context=resolve_edge_state_context, logger=logger, ) def _install_skill_zip_into_workspace( self, *, bot_id: str, zip_path: str, resolve_edge_state_context: EdgeStateContextResolver, logger: logging.Logger, ) -> Dict[str, Any]: try: archive = zipfile.ZipFile(zip_path) except Exception as exc: raise HTTPException(status_code=400, detail="Invalid zip file") from exc edge_context = resolve_edge_state_context(bot_id) skills_root = self._skills_root(bot_id) installed: List[str] = [] with archive: members = archive.infolist() file_members = [member for member in members if not member.is_dir()] if not file_members: raise HTTPException(status_code=400, detail="Zip package has no files") top_names: List[str] = [] for member in file_members: raw_name = str(member.filename or "").replace("\\", "/").lstrip("/") if not raw_name: continue first = raw_name.split("/", 1)[0].strip() if self._is_ignored_skill_zip_top_level(first): continue if not self._is_valid_top_level_skill_name(first): raise HTTPException(status_code=400, detail=f"Invalid skill entry name in zip: {first}") if first not in top_names: top_names.append(first) if not top_names: raise HTTPException(status_code=400, detail="Zip package has no valid skill entries") if edge_context is not None: existing_names = { str(item.get("name") or "").strip() for item in self.list_workspace_skills( bot_id=bot_id, resolve_edge_state_context=resolve_edge_state_context, logger=logger, ) if isinstance(item, dict) } conflicts = [name for name in top_names if name in existing_names] else: os.makedirs(skills_root, exist_ok=True) conflicts = [name for name in top_names if os.path.exists(os.path.join(skills_root, name))] if conflicts: raise HTTPException(status_code=400, detail=f"Skill already exists: {', '.join(conflicts)}") temp_dir_root = skills_root if edge_context is None else None with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=temp_dir_root) as tmp_dir: tmp_root = os.path.abspath(tmp_dir) for member in members: raw_name = str(member.filename or "").replace("\\", "/").lstrip("/") if not raw_name: continue target = os.path.abspath(os.path.join(tmp_root, raw_name)) if os.path.commonpath([tmp_root, target]) != tmp_root: raise HTTPException(status_code=400, detail=f"Unsafe zip entry path: {raw_name}") if member.is_dir(): os.makedirs(target, exist_ok=True) continue os.makedirs(os.path.dirname(target), exist_ok=True) with archive.open(member, "r") as source, open(target, "wb") as dest: shutil.copyfileobj(source, dest) if edge_context is not None: client, workspace_root, _node_id = edge_context upload_groups: Dict[str, List[str]] = {} for name in top_names: src = os.path.join(tmp_root, name) if not os.path.exists(src): continue if os.path.isfile(src): upload_groups.setdefault("skills", []).append(src) installed.append(name) continue for walk_root, _dirs, files in os.walk(src): for filename in files: local_path = os.path.join(walk_root, filename) relative_path = os.path.relpath(local_path, tmp_root).replace("\\", "/") relative_dir = os.path.dirname(relative_path).strip("/") target_dir = f"skills/{relative_dir}" if relative_dir else "skills" upload_groups.setdefault(target_dir, []).append(local_path) installed.append(name) for target_dir, local_paths in upload_groups.items(): client.upload_local_files( bot_id=bot_id, local_paths=local_paths, path=target_dir, workspace_root=workspace_root, ) else: for name in top_names: src = os.path.join(tmp_root, name) dst = os.path.join(skills_root, name) if not os.path.exists(src): continue shutil.move(src, dst) installed.append(name) if not installed: raise HTTPException(status_code=400, detail="No skill entries installed from zip") return { "installed": installed, "skills": self.list_workspace_skills( bot_id=bot_id, resolve_edge_state_context=resolve_edge_state_context, logger=logger, ), } def install_market_item_for_bot( self, *, bot_id: str, skill_id: int, session: Session, resolve_edge_state_context: EdgeStateContextResolver, logger: logging.Logger, ) -> Dict[str, Any]: item = session.get(SkillMarketItem, skill_id) if not item: raise HTTPException(status_code=404, detail="Skill market item not found") zip_path = os.path.join(self._skill_market_root(), str(item.zip_filename or "")) if not os.path.isfile(zip_path): raise HTTPException(status_code=404, detail="Skill zip package not found") install_row = session.exec( select(BotSkillInstall).where( BotSkillInstall.bot_id == bot_id, BotSkillInstall.skill_market_item_id == skill_id, ) ).first() try: install_result = self._install_skill_zip_into_workspace( bot_id=bot_id, zip_path=zip_path, resolve_edge_state_context=resolve_edge_state_context, logger=logger, ) now = datetime.utcnow() if not install_row: install_row = BotSkillInstall( bot_id=bot_id, skill_market_item_id=skill_id, ) install_row.installed_entries_json = json.dumps(install_result["installed"], ensure_ascii=False) install_row.source_zip_filename = str(item.zip_filename or "") install_row.status = "INSTALLED" install_row.last_error = None install_row.installed_at = now install_row.updated_at = now session.add(install_row) session.commit() session.refresh(install_row) return { "status": "installed", "bot_id": bot_id, "skill_market_item_id": skill_id, "installed": install_result["installed"], "skills": install_result["skills"], "market_item": self.serialize_skill_market_item(item, install_count=0, install_row=install_row), } except HTTPException as exc: now = datetime.utcnow() if not install_row: install_row = BotSkillInstall( bot_id=bot_id, skill_market_item_id=skill_id, installed_at=now, ) install_row.source_zip_filename = str(item.zip_filename or "") install_row.status = "FAILED" install_row.last_error = str(exc.detail or "Install failed") install_row.updated_at = now session.add(install_row) session.commit() raise except Exception as exc: now = datetime.utcnow() if not install_row: install_row = BotSkillInstall( bot_id=bot_id, skill_market_item_id=skill_id, installed_at=now, ) install_row.source_zip_filename = str(item.zip_filename or "") install_row.status = "FAILED" install_row.last_error = str(exc or "Install failed")[:1000] install_row.updated_at = now session.add(install_row) session.commit() raise HTTPException(status_code=500, detail="Skill install failed unexpectedly") from exc def install_market_item_for_bot_checked( self, *, session: Session, bot_id: str, skill_id: int, resolve_edge_state_context: EdgeStateContextResolver, logger: logging.Logger, ) -> Dict[str, Any]: self._require_bot(session=session, bot_id=bot_id) return self.install_market_item_for_bot( bot_id=bot_id, skill_id=skill_id, session=session, resolve_edge_state_context=resolve_edge_state_context, logger=logger, ) async def upload_bot_skill_zip( self, *, bot_id: str, file: UploadFile, resolve_edge_state_context: EdgeStateContextResolver, logger: logging.Logger, ) -> Dict[str, Any]: tmp_zip_path: Optional[str] = None try: with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip: tmp_zip_path = tmp_zip.name filename = str(file.filename or "").strip() if not filename.lower().endswith(".zip"): raise HTTPException(status_code=400, detail="Only .zip skill package is supported") max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024 total_size = 0 while True: chunk = await file.read(1024 * 1024) if not chunk: break total_size += len(chunk) if total_size > max_bytes: raise HTTPException( status_code=413, detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)", ) tmp_zip.write(chunk) if total_size == 0: raise HTTPException(status_code=400, detail="Zip package is empty") finally: await file.close() try: install_result = self._install_skill_zip_into_workspace( bot_id=bot_id, zip_path=tmp_zip_path, resolve_edge_state_context=resolve_edge_state_context, logger=logger, ) finally: if tmp_zip_path and os.path.exists(tmp_zip_path): os.remove(tmp_zip_path) return { "status": "installed", "bot_id": bot_id, "installed": install_result["installed"], "skills": install_result["skills"], } async def upload_bot_skill_zip_for_bot( self, *, session: Session, bot_id: str, file: UploadFile, resolve_edge_state_context: EdgeStateContextResolver, logger: logging.Logger, ) -> Dict[str, Any]: self._require_bot(session=session, bot_id=bot_id) return await self.upload_bot_skill_zip( bot_id=bot_id, file=file, resolve_edge_state_context=resolve_edge_state_context, logger=logger, ) def delete_workspace_skill( self, *, bot_id: str, skill_name: str, resolve_edge_state_context: EdgeStateContextResolver, ) -> Dict[str, Any]: if resolve_edge_state_context(bot_id) is not None: raise HTTPException( status_code=400, detail="Edge bot skill delete is disabled here. Use edge workspace file management.", ) name = str(skill_name or "").strip() if not self._is_valid_top_level_skill_name(name): raise HTTPException(status_code=400, detail="Invalid skill name") root = self._skills_root(bot_id) target = os.path.abspath(os.path.join(root, name)) if os.path.commonpath([os.path.abspath(root), target]) != os.path.abspath(root): raise HTTPException(status_code=400, detail="Invalid skill path") if not os.path.exists(target): raise HTTPException(status_code=404, detail="Skill not found in workspace") if os.path.isdir(target): shutil.rmtree(target, ignore_errors=False) else: os.remove(target) return {"status": "deleted", "bot_id": bot_id, "skill": name} def delete_workspace_skill_for_bot( self, *, session: Session, bot_id: str, skill_name: str, resolve_edge_state_context: EdgeStateContextResolver, ) -> Dict[str, Any]: self._require_bot(session=session, bot_id=bot_id) return self.delete_workspace_skill( bot_id=bot_id, skill_name=skill_name, resolve_edge_state_context=resolve_edge_state_context, )