"""Skills API — global skill registry CRUD.""" import asyncio import base64 import re import httpx from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import selectinload from app.database import async_session from app.models.skill import Skill, SkillFile from app.core.security import get_current_admin, get_current_user, require_role from app.models.user import User from loguru import logger router = APIRouter(prefix="/skills", tags=["skills"]) CLAWHUB_BASE = "https://clawhub.ai/api" GITHUB_API = "https://api.github.com" MAX_SKILL_SIZE = 512_000 # 500 KB total limit per skill async def _get_tenant_setting(tenant_id: str | None, key: str) -> str: """Resolve a tenant setting value: tenant_settings DB > empty.""" if tenant_id: try: from app.models.tenant_setting import TenantSetting import uuid as _uid async with async_session() as db: result = await db.execute( select(TenantSetting).where( TenantSetting.tenant_id == _uid.UUID(tenant_id), TenantSetting.key == key, ) ) setting = result.scalar_one_or_none() if setting and setting.value.get("token"): return setting.value["token"] except Exception: pass return "" async def _get_github_token(tenant_id: str | None = None) -> str: """Resolve GitHub token from tenant settings DB.""" return await _get_tenant_setting(tenant_id, "github_token") async def _get_clawhub_key(tenant_id: str | None = None) -> str: """Resolve ClawHub API key from tenant settings DB.""" return await _get_tenant_setting(tenant_id, "clawhub_key") def _clawhub_headers(api_key: str) -> dict: """Build request headers for ClawHub API calls.""" if api_key: return {"Authorization": f"Bearer {api_key}"} return {} class SkillFileIn(BaseModel): path: str content: str class SkillCreateIn(BaseModel): name: str description: str = "" category: str = "custom" icon: str = "📋" folder_name: str files: list[SkillFileIn] = [] class ClawhubInstallIn(BaseModel): slug: str class UrlImportIn(BaseModel): url: str # ─── Helpers ────────────────────────────────────────── def classify_portability(content: str) -> int: """Classify skill portability: 1=pure prompt, 2=CLI/API, 3=OpenClaw native.""" openclaw_markers = [ "bash pty:", "process action:", "Clawdbot", "exec tool", "openclaw.json", "imessage tool", "slack tool", ] cli_markers = [ "requires:", "bins:", 'env:', "OPENAI_API_KEY", "GITHUB_TOKEN", "python3", "brew ", "pip install", "npm install", "curl ", ] lower = content.lower() for kw in openclaw_markers: if kw.lower() in lower: return 3 for kw in cli_markers: if kw.lower() in lower: return 2 return 1 def _parse_skill_md_frontmatter(content: str) -> dict: """Extract YAML frontmatter from SKILL.md content.""" import yaml match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL) if not match: return {} try: return yaml.safe_load(match.group(1)) or {} except Exception: return {} def _parse_github_url(url: str) -> dict | None: """Parse a GitHub URL into owner/repo/branch/path components.""" # https://github.com/{owner}/{repo}/tree/{branch}/{path} m = re.match( r"https?://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*?)/?$", url ) if m: return {"owner": m.group(1), "repo": m.group(2), "branch": m.group(3), "path": m.group(4)} # https://github.com/{owner}/{repo}/{path} (assume main branch) m = re.match( r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", url ) if m: return {"owner": m.group(1), "repo": m.group(2), "branch": "main", "path": ""} return None def _apply_skill_scope(query, current_user: User): """Scope skill queries for tenant admins while leaving platform admins unrestricted.""" from sqlalchemy import or_ as _or if current_user.role == "platform_admin" or not current_user.tenant_id: return query return query.where(_or(Skill.tenant_id.is_(None), Skill.tenant_id == current_user.tenant_id)) def _ensure_skill_write_access(skill: Skill, current_user: User): """Allow platform admins to edit everything; tenant admins can edit tenant-owned skills AND builtin (preset) skills visible to their tenant. Builtin skills are treated as presets -- placed during company init, but fully manageable by org_admin afterwards. """ if current_user.role == "platform_admin": return if not current_user.tenant_id: raise HTTPException(403, "Cannot modify skills without a tenant") # Allow org_admin to manage: their own tenant skills OR builtin (preset) skills if skill.tenant_id is not None and skill.tenant_id != current_user.tenant_id: raise HTTPException(403, "Cannot modify other-tenant skills") async def _fetch_github_directory( owner: str, repo: str, path: str, branch: str = "main", token: str = "", ) -> list[dict]: """Recursively fetch all files from a GitHub directory via API. Returns [{"path": relative_path, "content": text}]. """ _token = token files: list[dict] = [] total_size = 0 max_depth = 3 # Prevent runaway recursion headers = {"Authorization": f"Bearer {_token}"} if _token else {} async def _recurse(dir_path: str, rel_prefix: str, depth: int = 0): nonlocal total_size if depth > max_depth: return api_url = f"{GITHUB_API}/repos/{owner}/{repo}/contents/{dir_path}?ref={branch}" async with httpx.AsyncClient(timeout=30, headers=headers) as client: resp = await client.get(api_url) if resp.status_code == 404: raise HTTPException(404, f"GitHub path not found: {dir_path}") if resp.status_code == 403: raise HTTPException(429, "GitHub API rate limit exceeded. Try again later.") if resp.status_code != 200: raise HTTPException(502, f"GitHub API error: {resp.status_code}") items = resp.json() if isinstance(items, dict): # Single file (not a directory) items = [items] # Early guard: if at top level, check that SKILL.md exists if depth == 0: has_skill_md = any( i["name"].upper() == "SKILL.MD" and i["type"] == "file" for i in items ) dir_count = sum(1 for i in items if i["type"] == "dir") if not has_skill_md: if dir_count > 5: raise HTTPException( 400, f"This directory contains {dir_count} subdirectories but no SKILL.md. " "Please provide the URL to a specific skill directory." ) raise HTTPException(400, "No SKILL.md found at the root of this directory — not a valid skill package.") for item in items: name = item["name"] rel = f"{rel_prefix}{name}" if rel_prefix else name if item["type"] == "dir": await _recurse(item["path"], f"{rel}/", depth + 1) elif item["type"] == "file": size = item.get("size", 0) total_size += size if total_size > MAX_SKILL_SIZE: raise HTTPException(413, f"Skill exceeds size limit ({MAX_SKILL_SIZE // 1024}KB)") # Download file content async with httpx.AsyncClient(timeout=30, headers=headers) as client: dl_resp = await client.get(item["url"]) if dl_resp.status_code == 200: data = dl_resp.json() content = base64.b64decode(data.get("content", "")).decode("utf-8", errors="replace") files.append({"path": rel, "content": content}) try: await _recurse(path, "") except HTTPException: raise except Exception as e: raise HTTPException(502, f"Failed to fetch files from GitHub: {e}") return files async def _save_skill_to_db( folder_name: str, name: str, description: str, category: str, icon: str, files: list[dict], source_url: str | None = None, tenant_id: str | None = None, ) -> dict: """Create a Skill + SkillFile records in the database.""" import uuid as _uuid async with async_session() as db: # Check for folder_name conflict (scoped by tenant) conflict_q = select(Skill).where(Skill.folder_name == folder_name) if tenant_id: conflict_q = conflict_q.where(Skill.tenant_id == _uuid.UUID(tenant_id)) else: conflict_q = conflict_q.where(Skill.tenant_id.is_(None)) existing = await db.execute(conflict_q) if existing.scalar_one_or_none(): raise HTTPException( 409, f"A skill with folder name '{folder_name}' already exists. " "Delete it first or use a different name." ) skill = Skill( name=name, description=description, category=category, icon=icon, folder_name=folder_name, is_builtin=False, tenant_id=_uuid.UUID(tenant_id) if tenant_id else None, ) db.add(skill) await db.flush() for f in files: # PostgreSQL text columns cannot store null bytes content = f["content"].replace("\x00", "") if f.get("content") else "" db.add(SkillFile(skill_id=skill.id, path=f["path"], content=content)) await db.commit() return {"id": str(skill.id), "name": skill.name, "folder_name": skill.folder_name} # ─── ClawHub Integration ───────────────────────────── @router.get("/clawhub/search") async def search_clawhub(q: str, current_user: User = Depends(get_current_user)): """Proxy search requests to the ClawHub API.""" tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None api_key = await _get_clawhub_key(tenant_id) async with httpx.AsyncClient(timeout=15) as client: resp = await client.get(f"{CLAWHUB_BASE}/search", params={"q": q}, headers=_clawhub_headers(api_key)) if resp.status_code != 200: raise HTTPException(502, f"ClawHub search failed: {resp.status_code}") data = resp.json() results = data.get("results", []) return [ { "slug": r.get("slug"), "displayName": r.get("displayName"), "summary": r.get("summary"), "score": r.get("score"), "version": r.get("version"), "updatedAt": r.get("updatedAt"), } for r in results ] @router.get("/clawhub/detail/{slug}") async def clawhub_detail(slug: str, current_user: User = Depends(get_current_user)): """Fetch full metadata for a skill from ClawHub.""" tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None api_key = await _get_clawhub_key(tenant_id) try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.get(f"{CLAWHUB_BASE}/v1/skills/{slug}", headers=_clawhub_headers(api_key)) if resp.status_code == 404: raise HTTPException(404, f"Skill '{slug}' not found on ClawHub") if resp.status_code == 429: raise HTTPException(429, "ClawHub rate limit exceeded. Please wait a moment and try again.") if resp.status_code != 200: raise HTTPException(502, f"ClawHub API error: {resp.status_code}") return resp.json() except HTTPException: raise except Exception as e: raise HTTPException(502, f"Failed to connect to ClawHub: {e}") @router.post("/clawhub/install") async def install_from_clawhub(body: ClawhubInstallIn, current_user: User = Depends(get_current_user)): """Install a skill from ClawHub into the global registry.""" # Resolve tenant GitHub token tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None token = await _get_github_token(tenant_id) slug = body.slug # 1. Fetch metadata from ClawHub (with retry for rate limits) api_key = await _get_clawhub_key(tenant_id) ch_headers = _clawhub_headers(api_key) meta = None for attempt in range(3): try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.get(f"{CLAWHUB_BASE}/v1/skills/{slug}", headers=ch_headers) if resp.status_code == 404: raise HTTPException(404, f"Skill '{slug}' not found on ClawHub") if resp.status_code == 429: if attempt < 2: await asyncio.sleep(1 + attempt) # 1s, 2s backoff continue raise HTTPException(429, "ClawHub rate limit exceeded. Please wait a moment and try again.") if resp.status_code != 200: raise HTTPException(502, f"ClawHub API error: {resp.status_code}") meta = resp.json() break except HTTPException: raise except Exception as e: if attempt < 2: await asyncio.sleep(1) continue raise HTTPException(502, f"Failed to connect to ClawHub: {e}") skill_info = meta.get("skill", {}) owner_info = meta.get("owner", {}) moderation = meta.get("moderation") or {} handle = owner_info.get("handle", "").lower() if not handle: raise HTTPException(400, "Could not determine skill owner handle from ClawHub") # 2. Build result with moderation warning is_suspicious = moderation.get("isSuspicious", False) moderation_summary = moderation.get("summary", "") # 3. Fetch files from GitHub archive github_path = f"skills/{handle}/{slug}" try: files = await _fetch_github_directory("openclaw", "skills", github_path, "main", token=token) except HTTPException as e: if e.status_code == 404: raise HTTPException( 404, f"Skill files not found in GitHub archive at {github_path}. " "Try importing via URL instead." ) raise if not files: raise HTTPException(404, "No files found in the skill directory") # 4. Extract name/description from SKILL.md skill_md = next((f for f in files if f["path"].upper() == "SKILL.MD"), None) if not skill_md: raise HTTPException(400, "No SKILL.md found — not a valid skill package") frontmatter = _parse_skill_md_frontmatter(skill_md["content"]) name = frontmatter.get("name", skill_info.get("displayName", slug)) description = frontmatter.get("description", skill_info.get("summary", "")) # 5. Classify portability tier tier = classify_portability(skill_md["content"]) tier_labels = {1: "clawhub-tier1", 2: "clawhub-tier2", 3: "clawhub-tier3"} has_scripts = any("/" in f["path"] for f in files if f["path"] != "SKILL.md") # 6. Save to DB result = await _save_skill_to_db( folder_name=slug, name=name, description=description, category=tier_labels.get(tier, "clawhub"), icon="", files=files, source_url=f"https://clawhub.ai/skills/{slug}", tenant_id=tenant_id, ) result["tier"] = tier result["is_suspicious"] = is_suspicious result["moderation_summary"] = moderation_summary result["has_scripts"] = has_scripts result["file_count"] = len(files) result["source"] = "clawhub" return result @router.post("/import-from-url") async def import_from_url(body: UrlImportIn, current_user: User = Depends(get_current_user)): """Import a skill from any GitHub URL into the global registry.""" tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None token = await _get_github_token(tenant_id) parsed = _parse_github_url(body.url) if not parsed: raise HTTPException(400, "Invalid GitHub URL. Expected format: https://github.com/{owner}/{repo}/tree/{branch}/{path}") owner, repo, branch, path = parsed["owner"], parsed["repo"], parsed["branch"], parsed["path"] # Fetch files files = await _fetch_github_directory(owner, repo, path, branch, token=token) if not files: raise HTTPException(404, "No files found at the specified path") # Validate SKILL.md exists skill_md = next((f for f in files if f["path"].upper() == "SKILL.MD"), None) if not skill_md: raise HTTPException(400, "No SKILL.md found at this URL — not a valid skill package") frontmatter = _parse_skill_md_frontmatter(skill_md["content"]) name = frontmatter.get("name", path.rstrip("/").split("/")[-1] if path else repo) description = frontmatter.get("description", "") # Derive folder_name from the last path segment folder_name = path.rstrip("/").split("/")[-1] if path else repo tier = classify_portability(skill_md["content"]) tier_labels = {1: "url-import-tier1", 2: "url-import-tier2", 3: "url-import-tier3"} result = await _save_skill_to_db( folder_name=folder_name, name=name, description=description, category=tier_labels.get(tier, "url-import"), icon="", files=files, source_url=body.url, tenant_id=tenant_id, ) result["tier"] = tier result["file_count"] = len(files) result["source"] = "url" return result @router.post("/import-from-url/preview") async def preview_url_import(body: UrlImportIn, current_user: User = Depends(get_current_user)): """Preview what will be imported from a GitHub URL without saving.""" tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None token = await _get_github_token(tenant_id) parsed = _parse_github_url(body.url) if not parsed: raise HTTPException(400, "Invalid GitHub URL format") owner, repo, branch, path = parsed["owner"], parsed["repo"], parsed["branch"], parsed["path"] files = await _fetch_github_directory(owner, repo, path, branch, token=token) if not files: raise HTTPException(404, "No files found at the specified path") skill_md = next((f for f in files if f["path"].upper() == "SKILL.MD"), None) if not skill_md: raise HTTPException(400, "No SKILL.md found — not a valid skill package") frontmatter = _parse_skill_md_frontmatter(skill_md["content"]) tier = classify_portability(skill_md["content"]) return { "name": frontmatter.get("name", path.rstrip("/").split("/")[-1] if path else repo), "description": frontmatter.get("description", ""), "tier": tier, "files": [{"path": f["path"], "size": len(f["content"])} for f in files], "total_size": sum(len(f["content"]) for f in files), "has_scripts": any("/" in f["path"] for f in files if f["path"] != "SKILL.md"), } # ─── Standard CRUD ──────────────────────────────────── @router.get("/") async def list_skills(current_user: User = Depends(get_current_user)): """List global skills scoped by tenant (builtin + tenant-specific).""" import uuid as _uuid from sqlalchemy import or_ as _or tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None async with async_session() as db: query = select(Skill).order_by(Skill.name) # Scope by tenant: show builtin (tenant_id is NULL) + tenant-specific skills if tenant_id: query = query.where(_or(Skill.tenant_id.is_(None), Skill.tenant_id == _uuid.UUID(tenant_id))) result = await db.execute(query) skills = result.scalars().all() return [ { "id": str(s.id), "name": s.name, "description": s.description, "category": s.category, "icon": s.icon, "folder_name": s.folder_name, "is_builtin": s.is_builtin, "is_default": s.is_default, "created_at": s.created_at.isoformat() if s.created_at else None, } for s in skills ] @router.get("/{skill_id}") async def get_skill(skill_id: str, current_user: User = Depends(get_current_user)): """Get a skill with its files.""" async with async_session() as db: query = select(Skill).where(Skill.id == skill_id).options(selectinload(Skill.files)) result = await db.execute(_apply_skill_scope(query, current_user)) skill = result.scalar_one_or_none() if not skill: raise HTTPException(404, "Skill not found") return { "id": str(skill.id), "name": skill.name, "description": skill.description, "category": skill.category, "icon": skill.icon, "folder_name": skill.folder_name, "is_builtin": skill.is_builtin, "files": [ {"path": f.path, "content": f.content} for f in skill.files ], } @router.post("/") async def create_skill(body: SkillCreateIn, current_user: User = Depends(get_current_admin)): """Create a custom skill.""" async with async_session() as db: skill = Skill( name=body.name, description=body.description, category=body.category, icon=body.icon, folder_name=body.folder_name, is_builtin=False, tenant_id=current_user.tenant_id, ) db.add(skill) await db.flush() if not body.files: # Auto-create a SKILL.md template db.add(SkillFile( skill_id=skill.id, path="SKILL.md", content=f"---\nname: {body.name}\ndescription: {body.description}\n---\n\n# {body.name}\n\n## Overview\n{body.description}\n", )) else: for f in body.files: db.add(SkillFile(skill_id=skill.id, path=f.path, content=f.content)) await db.commit() return {"id": str(skill.id), "name": skill.name} class SkillUpdateIn(BaseModel): name: str | None = None description: str | None = None category: str | None = None icon: str | None = None files: list[SkillFileIn] | None = None @router.put("/{skill_id}") async def update_skill(skill_id: str, body: SkillUpdateIn, current_user: User = Depends(get_current_admin)): """Update a skill's metadata and/or files.""" async with async_session() as db: query = select(Skill).where(Skill.id == skill_id).options(selectinload(Skill.files)) result = await db.execute(_apply_skill_scope(query, current_user)) skill = result.scalar_one_or_none() if not skill: raise HTTPException(404, "Skill not found") _ensure_skill_write_access(skill, current_user) if body.name is not None: skill.name = body.name if body.description is not None: skill.description = body.description if body.category is not None: skill.category = body.category if body.icon is not None: skill.icon = body.icon # Replace files if provided if body.files is not None: for f in skill.files: await db.delete(f) await db.flush() for f in body.files: db.add(SkillFile(skill_id=skill.id, path=f.path, content=f.content)) await db.commit() return {"id": str(skill.id), "name": skill.name} @router.delete("/{skill_id}") async def delete_skill(skill_id: str, current_user: User = Depends(get_current_admin)): """Delete a skill (not builtin).""" async with async_session() as db: query = select(Skill).where(Skill.id == skill_id) result = await db.execute(_apply_skill_scope(query, current_user)) skill = result.scalar_one_or_none() if not skill: raise HTTPException(404, "Skill not found") _ensure_skill_write_access(skill, current_user) await db.delete(skill) await db.commit() return {"ok": True} # ─── Tenant GitHub Token Settings ─────────────────────────── class SkillSettingsIn(BaseModel): github_token: str | None = None clawhub_key: str | None = None async def _upsert_tenant_setting(tenant_id, key: str, value: str): """Helper to upsert a tenant setting.""" from app.models.tenant_setting import TenantSetting async with async_session() as db: result = await db.execute( select(TenantSetting).where( TenantSetting.tenant_id == tenant_id, TenantSetting.key == key, ) ) existing = result.scalar_one_or_none() if existing: existing.value = {"token": value} else: db.add(TenantSetting( tenant_id=tenant_id, key=key, value={"token": value}, )) await db.commit() def _mask_token(token: str) -> str: if token and len(token) > 8: return f"{token[:4]}...{token[-4:]}" return "****" if token else "" @router.get("/settings/token") async def get_skill_token_status( current_user=Depends(require_role("org_admin", "platform_admin")), ): """Check if GitHub token and ClawHub key are configured for this tenant.""" tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None gh_token = await _get_github_token(tenant_id) ch_key = await _get_clawhub_key(tenant_id) return { "configured": bool(gh_token), "source": "tenant" if tenant_id else "env", "masked": _mask_token(gh_token), "clawhub_configured": bool(ch_key), "clawhub_masked": _mask_token(ch_key), } @router.put("/settings/token") async def set_skill_token( body: SkillSettingsIn, current_user=Depends(require_role("org_admin", "platform_admin")), ): """Save GitHub token and/or ClawHub key for this tenant. Accessible by org_admin (to manage their own company's credentials) and platform_admin. require_role performs exact-match checks, so both roles must be listed explicitly. """ if not current_user.tenant_id: raise HTTPException(400, "No tenant associated") if body.github_token is not None: await _upsert_tenant_setting(current_user.tenant_id, "github_token", body.github_token) if body.clawhub_key is not None: await _upsert_tenant_setting(current_user.tenant_id, "clawhub_key", body.clawhub_key) return {"ok": True} # ─── Path-based browse endpoints for FileBrowser ─────────── @router.get("/browse/list") async def browse_list(path: str = "", current_user: User = Depends(get_current_user)): """List skill folders (root) or files/subdirs within a skill folder.""" import uuid as _uuid from sqlalchemy import or_ as _or tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None async with async_session() as db: if not path or path == "/": # Root: list all skill folders (scoped by tenant) query = select(Skill).order_by(Skill.name) if tenant_id: query = query.where(_or(Skill.tenant_id.is_(None), Skill.tenant_id == _uuid.UUID(tenant_id))) result = await db.execute(query) skills = result.scalars().all() return [ {"name": s.folder_name, "path": s.folder_name, "is_dir": True, "size": 0} for s in skills ] # Inside a skill folder — resolve the skill and relative subpath clean = path.strip("/") folder = clean.split("/")[0] # Resolve skill folder scoped by tenant skill_q = select(Skill).where(Skill.folder_name == folder).options(selectinload(Skill.files)) if tenant_id: skill_q = skill_q.where(_or(Skill.tenant_id.is_(None), Skill.tenant_id == _uuid.UUID(tenant_id))) result = await db.execute(skill_q) skill = result.scalar_one_or_none() if not skill: return [] # Calculate the relative prefix within the skill (empty = skill root) sub = clean[len(folder):].strip("/") # e.g. "" or "scripts" or "scripts/sub" items = [] seen_dirs: set[str] = set() for f in skill.files: if sub: # Only files that start with this sub prefix if not f.path.startswith(sub + "/"): continue remainder = f.path[len(sub) + 1:] # strip "scripts/" prefix else: remainder = f.path if "/" in remainder: # This file is in a subdirectory — show the directory dir_name = remainder.split("/")[0] if dir_name not in seen_dirs: seen_dirs.add(dir_name) dir_path = f"{folder}/{sub}/{dir_name}" if sub else f"{folder}/{dir_name}" items.append({"name": dir_name, "path": dir_path, "is_dir": True, "size": 0}) else: # Direct child file file_path = f"{folder}/{f.path}" items.append({"name": remainder, "path": file_path, "is_dir": False, "size": len(f.content.encode())}) return items @router.get("/browse/read") async def browse_read(path: str, current_user: User = Depends(get_current_user)): """Read a file from a skill folder.""" import uuid as _uuid from sqlalchemy import or_ as _or tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None parts = path.strip("/").split("/", 1) if len(parts) < 2: raise HTTPException(400, "Path must include folder and file") folder, file_path = parts async with async_session() as db: skill_q = select(Skill).where(Skill.folder_name == folder).options(selectinload(Skill.files)) if tenant_id: skill_q = skill_q.where(_or(Skill.tenant_id.is_(None), Skill.tenant_id == _uuid.UUID(tenant_id))) result = await db.execute(skill_q) skill = result.scalar_one_or_none() if not skill: raise HTTPException(404, "Skill not found") for f in skill.files: if f.path == file_path: return {"content": f.content} raise HTTPException(404, "File not found") class BrowseWriteIn(BaseModel): path: str content: str @router.put("/browse/write") async def browse_write(body: BrowseWriteIn, current_user: User = Depends(get_current_admin)): """Write a file in a skill folder. Creates the skill if the folder doesn't exist.""" parts = body.path.strip("/").split("/", 1) if len(parts) < 2: raise HTTPException(400, "Path must include folder and file") folder, file_path = parts async with async_session() as db: skill_q = select(Skill).where(Skill.folder_name == folder).options(selectinload(Skill.files)) result = await db.execute(_apply_skill_scope(skill_q, current_user)) skill = result.scalar_one_or_none() created_new_skill = False if not skill: # Auto-create skill from folder name, scoped to tenant skill = Skill( name=folder.replace("-", " ").title(), description="", category="custom", icon="--", folder_name=folder, is_builtin=False, tenant_id=current_user.tenant_id, ) db.add(skill) await db.flush() created_new_skill = True else: _ensure_skill_write_access(skill, current_user) # Upsert file existing = None if not created_new_skill: for f in skill.files: if f.path == file_path: existing = f break if existing: existing.content = body.content else: db.add(SkillFile(skill_id=skill.id, path=file_path, content=body.content)) await db.commit() return {"ok": True} @router.delete("/browse/delete") async def browse_delete(path: str, current_user: User = Depends(get_current_admin)): """Delete a file or an entire skill folder.""" parts = path.strip("/").split("/", 1) folder = parts[0] async with async_session() as db: skill_q = select(Skill).where(Skill.folder_name == folder).options(selectinload(Skill.files)) result = await db.execute(_apply_skill_scope(skill_q, current_user)) skill = result.scalar_one_or_none() if not skill: raise HTTPException(404, "Skill not found") _ensure_skill_write_access(skill, current_user) if len(parts) == 1: # Delete entire skill await db.delete(skill) else: # Delete specific file file_path = parts[1] for f in skill.files: if f.path == file_path: await db.delete(f) break await db.commit() return {"ok": True}