| 1 | from __future__ import annotations |
| 2 | |
| 3 | import os |
| 4 | import time |
| 5 | import uuid |
| 6 | from pathlib import Path |
| 7 | |
| 8 | from helpers.api import ApiHandler, Request, Response |
| 9 | from helpers import files |
| 10 | from helpers.skills_import import import_skills |
| 11 | from werkzeug.datastructures import FileStorage |
| 12 | from werkzeug.utils import secure_filename |
| 13 | |
| 14 | |
| 15 | class SkillsImportPreview(ApiHandler): |
| 16 | """ |
| 17 | Preview importing an external skills pack (.zip) into usr/skills/<namespace>/... |
| 18 | Uses dry-run (no copying). |
| 19 | """ |
| 20 | |
| 21 | async def process(self, input: dict, request: Request) -> dict | Response: |
| 22 | if "skills_file" not in request.files: |
| 23 | return {"success": False, "error": "No skills file provided"} |
| 24 | |
| 25 | skills_file: FileStorage = request.files["skills_file"] |
| 26 | if not skills_file.filename: |
| 27 | return {"success": False, "error": "No file selected"} |
| 28 | |
| 29 | ctxid = request.form.get("ctxid", "") |
| 30 | if not ctxid: |
| 31 | return {"success": False, "error": "No context id provided"} |
| 32 | _context = self.use_context(ctxid) |
| 33 | |
| 34 | conflict = (request.form.get("conflict", "skip") or "skip").strip().lower() |
| 35 | if conflict not in ("skip", "overwrite", "rename"): |
| 36 | conflict = "skip" |
| 37 | |
| 38 | namespace = (request.form.get("namespace", "") or "").strip() or None |
| 39 | project_name = (request.form.get("project_name", "") or "").strip() or None |
| 40 | agent_profile = (request.form.get("agent_profile", "") or "").strip() or None |
| 41 | |
| 42 | # Save upload to a temp file so we can pass a filesystem path to the importer |
| 43 | tmp_dir = Path(files.get_abs_path("tmp", "uploads")) |
| 44 | tmp_dir.mkdir(parents=True, exist_ok=True) |
| 45 | base = secure_filename(skills_file.filename) # type: ignore[arg-type] |
| 46 | if not base.lower().endswith(".zip"): |
| 47 | base = f"{base}.zip" |
| 48 | unique = uuid.uuid4().hex[:8] |
| 49 | stamp = time.strftime("%Y%m%d_%H%M%S") |
| 50 | tmp_path = tmp_dir / f"skills_import_preview_{stamp}_{unique}_{base}" |
| 51 | skills_file.save(str(tmp_path)) |
| 52 | |
| 53 | try: |
| 54 | result = import_skills( |
| 55 | str(tmp_path), |
| 56 | namespace=namespace, |
| 57 | conflict=conflict, # type: ignore[arg-type] |
| 58 | dry_run=True, |
| 59 | project_name=project_name, |
| 60 | agent_profile=agent_profile, |
| 61 | ) |
| 62 | |
| 63 | imported = [files.deabsolute_path(str(p)) for p in result.imported] |
| 64 | skipped = [files.deabsolute_path(str(p)) for p in result.skipped] |
| 65 | dest_root = files.deabsolute_path(str(result.destination_root / result.namespace)) |
| 66 | |
| 67 | return { |
| 68 | "success": True, |
| 69 | "namespace": result.namespace, |
| 70 | "destination": dest_root, |
| 71 | "imported": imported, |
| 72 | "skipped": skipped, |
| 73 | "imported_count": len(imported), |
| 74 | "skipped_count": len(skipped), |
| 75 | "conflict_policy": conflict, |
| 76 | } |
| 77 | finally: |
| 78 | try: |
| 79 | tmp_path.unlink(missing_ok=True) # type: ignore[arg-type] |
| 80 | except Exception: |
| 81 | pass |
| 82 |