Clawith/backend/app/api/skills.py

883 lines
33 KiB
Python

"""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}