2026-02-21 02:48:20 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
import zipfile
|
|
|
|
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
from src import config as sys_config
|
|
|
|
|
|
from src.repositories.skill_repository import SkillRepository
|
2026-02-22 17:42:37 +08:00
|
|
|
|
from src.services.mcp_service import get_mcp_server_names
|
2026-02-21 02:48:20 +08:00
|
|
|
|
from src.storage.postgres.manager import pg_manager
|
|
|
|
|
|
from src.storage.postgres.models_business import Skill
|
|
|
|
|
|
from src.utils.logging_config import logger
|
|
|
|
|
|
|
|
|
|
|
|
SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
|
|
|
|
FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
|
|
|
|
|
|
|
|
|
|
TEXT_FILE_EXTENSIONS = {
|
|
|
|
|
|
".md",
|
|
|
|
|
|
".txt",
|
|
|
|
|
|
".py",
|
|
|
|
|
|
".js",
|
|
|
|
|
|
".ts",
|
|
|
|
|
|
".json",
|
|
|
|
|
|
".yaml",
|
|
|
|
|
|
".yml",
|
|
|
|
|
|
".toml",
|
|
|
|
|
|
".ini",
|
|
|
|
|
|
".cfg",
|
|
|
|
|
|
".conf",
|
|
|
|
|
|
".xml",
|
|
|
|
|
|
".html",
|
|
|
|
|
|
".css",
|
|
|
|
|
|
".sql",
|
|
|
|
|
|
".sh",
|
|
|
|
|
|
".bat",
|
|
|
|
|
|
".ps1",
|
|
|
|
|
|
".env",
|
|
|
|
|
|
".csv",
|
|
|
|
|
|
".tsv",
|
|
|
|
|
|
".rst",
|
|
|
|
|
|
".ipynb",
|
|
|
|
|
|
".vue",
|
|
|
|
|
|
".jsx",
|
|
|
|
|
|
".tsx",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_skill_options_cache: list[dict[str, str]] = []
|
|
|
|
|
|
_skill_prompt_metadata_cache: dict[str, dict[str, str]] = {}
|
2026-02-22 17:42:37 +08:00
|
|
|
|
_skill_dependency_cache: dict[str, dict[str, list[str]]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_string_list(values: list[str] | None) -> list[str]:
|
|
|
|
|
|
if not values:
|
|
|
|
|
|
return []
|
|
|
|
|
|
normalized: list[str] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
for value in values:
|
|
|
|
|
|
if not isinstance(value, str):
|
|
|
|
|
|
continue
|
|
|
|
|
|
item = value.strip()
|
|
|
|
|
|
if not item or item in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(item)
|
|
|
|
|
|
normalized.append(item)
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_buildin_tool_names() -> list[str]:
|
|
|
|
|
|
from src.agents.common.tools import get_buildin_tools
|
|
|
|
|
|
|
|
|
|
|
|
return [tool.name for tool in get_buildin_tools()]
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_skills_root_dir() -> Path:
|
|
|
|
|
|
root = Path(sys_config.save_dir) / "skills"
|
|
|
|
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
return root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_skill_options() -> list[dict[str, str]]:
|
|
|
|
|
|
"""返回技能选项缓存(用于 BaseContext configurable options)。"""
|
|
|
|
|
|
return list(_skill_options_cache)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 17:42:37 +08:00
|
|
|
|
def get_skill_dependency_options() -> dict[str, list[str]]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"tools": _get_buildin_tool_names(),
|
|
|
|
|
|
"mcps": get_mcp_server_names(),
|
|
|
|
|
|
"skills": [item["id"] for item in _skill_options_cache],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 02:48:20 +08:00
|
|
|
|
def get_skill_prompt_metadata_by_slugs(slugs: list[str]) -> list[dict[str, str]]:
|
|
|
|
|
|
"""按 slug 顺序返回 skills prompt 元数据(仅缓存,无 IO)。"""
|
|
|
|
|
|
if not slugs:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
result: list[dict[str, str]] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
for slug in slugs:
|
|
|
|
|
|
if slug in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(slug)
|
|
|
|
|
|
|
|
|
|
|
|
item = _skill_prompt_metadata_cache.get(slug)
|
|
|
|
|
|
if not item:
|
|
|
|
|
|
logger.debug(f"Skill slug not found in cache, skip prompt metadata: {slug}")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
result.append(dict(item))
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 17:42:37 +08:00
|
|
|
|
def expand_skill_closure(slugs: list[str]) -> list[str]:
|
|
|
|
|
|
"""递归展开 skill 依赖(仅缓存,无 IO),去重保序并去环。"""
|
|
|
|
|
|
ordered_roots = _normalize_string_list(slugs)
|
|
|
|
|
|
if not ordered_roots:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
result: list[str] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
def dfs(slug: str, stack: set[str]) -> None:
|
|
|
|
|
|
if slug in stack:
|
|
|
|
|
|
logger.warning(f"Cycle detected in skill dependencies, skip: {' -> '.join([*stack, slug])}")
|
|
|
|
|
|
return
|
|
|
|
|
|
if slug in seen:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
node = _skill_dependency_cache.get(slug)
|
|
|
|
|
|
if not node:
|
|
|
|
|
|
logger.warning(f"Skill dependency target not found in cache, skip: {slug}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
seen.add(slug)
|
|
|
|
|
|
result.append(slug)
|
|
|
|
|
|
next_stack = set(stack)
|
|
|
|
|
|
next_stack.add(slug)
|
|
|
|
|
|
for dep in node.get("skills", []):
|
|
|
|
|
|
dfs(dep, next_stack)
|
|
|
|
|
|
|
|
|
|
|
|
for root in ordered_roots:
|
|
|
|
|
|
dfs(root, set())
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_dependency_bundle_for_activated_skills(activated_slugs: list[str]) -> dict[str, list[str]]:
|
|
|
|
|
|
closure = expand_skill_closure(activated_slugs)
|
|
|
|
|
|
tools: list[str] = []
|
|
|
|
|
|
mcps: list[str] = []
|
|
|
|
|
|
seen_tools: set[str] = set()
|
|
|
|
|
|
seen_mcps: set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
for slug in closure:
|
|
|
|
|
|
dep = _skill_dependency_cache.get(slug, {})
|
|
|
|
|
|
for tool_name in dep.get("tools", []):
|
|
|
|
|
|
if tool_name in seen_tools:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen_tools.add(tool_name)
|
|
|
|
|
|
tools.append(tool_name)
|
|
|
|
|
|
for server_name in dep.get("mcps", []):
|
|
|
|
|
|
if server_name in seen_mcps:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen_mcps.add(server_name)
|
|
|
|
|
|
mcps.append(server_name)
|
|
|
|
|
|
return {"tools": tools, "mcps": mcps, "skills": closure}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_expanded_visible_skill_slugs(selected_slugs: list[str]) -> list[str]:
|
|
|
|
|
|
"""展开运行时可见 skills(根 skills + 递归依赖)。"""
|
|
|
|
|
|
return expand_skill_closure(selected_slugs)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 02:48:20 +08:00
|
|
|
|
def _set_skill_options_cache(items: list[Skill]) -> None:
|
2026-02-22 17:42:37 +08:00
|
|
|
|
global _skill_options_cache, _skill_prompt_metadata_cache, _skill_dependency_cache
|
2026-02-21 02:48:20 +08:00
|
|
|
|
_skill_options_cache = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": item.slug,
|
|
|
|
|
|
"name": item.name,
|
|
|
|
|
|
"description": item.description,
|
|
|
|
|
|
}
|
|
|
|
|
|
for item in items
|
|
|
|
|
|
]
|
|
|
|
|
|
_skill_prompt_metadata_cache = {
|
|
|
|
|
|
item.slug: {
|
|
|
|
|
|
"name": item.name,
|
|
|
|
|
|
"description": item.description,
|
|
|
|
|
|
"path": f"/skills/{item.slug}/SKILL.md",
|
|
|
|
|
|
}
|
|
|
|
|
|
for item in items
|
|
|
|
|
|
}
|
2026-02-22 17:42:37 +08:00
|
|
|
|
_skill_dependency_cache = {
|
|
|
|
|
|
item.slug: {
|
|
|
|
|
|
"tools": _normalize_string_list(item.tool_dependencies or []),
|
|
|
|
|
|
"mcps": _normalize_string_list(item.mcp_dependencies or []),
|
|
|
|
|
|
"skills": _normalize_string_list(item.skill_dependencies or []),
|
|
|
|
|
|
}
|
|
|
|
|
|
for item in items
|
|
|
|
|
|
}
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def init_skills_cache() -> None:
|
|
|
|
|
|
"""启动时加载技能缓存,避免每次构建 configurable_items 触发 DB IO。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with pg_manager.get_async_session_context() as session:
|
|
|
|
|
|
repo = SkillRepository(session)
|
|
|
|
|
|
items = await repo.list_all()
|
|
|
|
|
|
_set_skill_options_cache(items)
|
|
|
|
|
|
logger.info(f"Loaded skills cache with {len(items)} items")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Failed to initialize skills cache: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def list_skills(db: AsyncSession) -> list[Skill]:
|
|
|
|
|
|
repo = SkillRepository(db)
|
|
|
|
|
|
items = await repo.list_all()
|
|
|
|
|
|
_set_skill_options_cache(items)
|
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 17:42:37 +08:00
|
|
|
|
def _validate_dependencies(
|
|
|
|
|
|
*,
|
|
|
|
|
|
slug: str,
|
|
|
|
|
|
tool_dependencies: list[str],
|
|
|
|
|
|
mcp_dependencies: list[str],
|
|
|
|
|
|
skill_dependencies: list[str],
|
|
|
|
|
|
) -> tuple[list[str], list[str], list[str]]:
|
|
|
|
|
|
tools = _normalize_string_list(tool_dependencies)
|
|
|
|
|
|
mcps = _normalize_string_list(mcp_dependencies)
|
|
|
|
|
|
skills = _normalize_string_list(skill_dependencies)
|
|
|
|
|
|
|
|
|
|
|
|
available_tools = set(_get_buildin_tool_names())
|
|
|
|
|
|
invalid_tools = [name for name in tools if name not in available_tools]
|
|
|
|
|
|
if invalid_tools:
|
|
|
|
|
|
raise ValueError(f"存在无效工具依赖: {', '.join(invalid_tools)}")
|
|
|
|
|
|
|
|
|
|
|
|
available_mcps = set(get_mcp_server_names())
|
|
|
|
|
|
invalid_mcps = [name for name in mcps if name not in available_mcps]
|
|
|
|
|
|
if invalid_mcps:
|
|
|
|
|
|
raise ValueError(f"存在无效 MCP 依赖: {', '.join(invalid_mcps)}")
|
|
|
|
|
|
|
|
|
|
|
|
available_skills = {item["id"] for item in _skill_options_cache}
|
|
|
|
|
|
invalid_skills = [name for name in skills if name not in available_skills]
|
|
|
|
|
|
if invalid_skills:
|
|
|
|
|
|
raise ValueError(f"存在无效 skill 依赖: {', '.join(invalid_skills)}")
|
|
|
|
|
|
|
|
|
|
|
|
if slug in skills:
|
|
|
|
|
|
raise ValueError("skill_dependencies 不允许包含自身")
|
|
|
|
|
|
|
|
|
|
|
|
return tools, mcps, skills
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def update_skill_dependencies(
|
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
|
*,
|
|
|
|
|
|
slug: str,
|
|
|
|
|
|
tool_dependencies: list[str],
|
|
|
|
|
|
mcp_dependencies: list[str],
|
|
|
|
|
|
skill_dependencies: list[str],
|
|
|
|
|
|
updated_by: str | None,
|
|
|
|
|
|
) -> Skill:
|
|
|
|
|
|
item = await get_skill_or_raise(db, slug)
|
|
|
|
|
|
repo = SkillRepository(db)
|
|
|
|
|
|
# 写操作前先同步一次缓存,确保依赖校验基于最新技能集合。
|
|
|
|
|
|
_set_skill_options_cache(await repo.list_all())
|
|
|
|
|
|
tools, mcps, skills = _validate_dependencies(
|
|
|
|
|
|
slug=slug,
|
|
|
|
|
|
tool_dependencies=tool_dependencies,
|
|
|
|
|
|
mcp_dependencies=mcp_dependencies,
|
|
|
|
|
|
skill_dependencies=skill_dependencies,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
updated = await repo.update_dependencies(
|
|
|
|
|
|
item,
|
|
|
|
|
|
tool_dependencies=tools,
|
|
|
|
|
|
mcp_dependencies=mcps,
|
|
|
|
|
|
skill_dependencies=skills,
|
|
|
|
|
|
updated_by=updated_by,
|
|
|
|
|
|
)
|
|
|
|
|
|
_set_skill_options_cache(await repo.list_all())
|
|
|
|
|
|
return updated
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 02:48:20 +08:00
|
|
|
|
def _validate_skill_name(name: str) -> str:
|
|
|
|
|
|
name = name.strip()
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
raise ValueError("SKILL.md frontmatter 缺少 name")
|
|
|
|
|
|
if len(name) > 128:
|
|
|
|
|
|
raise ValueError("skill name 长度不能超过 128")
|
|
|
|
|
|
if not SKILL_NAME_PATTERN.match(name):
|
|
|
|
|
|
raise ValueError("skill name 必须是小写字母/数字/短横线,且不能连续短横线")
|
|
|
|
|
|
return name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_skill_markdown(content: str) -> tuple[str, str, dict[str, Any]]:
|
|
|
|
|
|
match = FRONTMATTER_PATTERN.match(content)
|
|
|
|
|
|
if not match:
|
|
|
|
|
|
raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)")
|
|
|
|
|
|
|
|
|
|
|
|
frontmatter_raw = match.group(1)
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = yaml.safe_load(frontmatter_raw)
|
|
|
|
|
|
except yaml.YAMLError as e:
|
|
|
|
|
|
raise ValueError(f"SKILL.md frontmatter YAML 解析失败: {e}") from e
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
|
raise ValueError("SKILL.md frontmatter 必须是对象")
|
|
|
|
|
|
|
|
|
|
|
|
name = _validate_skill_name(str(data.get("name", "")))
|
|
|
|
|
|
description = str(data.get("description", "")).strip()
|
|
|
|
|
|
if not description:
|
|
|
|
|
|
raise ValueError("SKILL.md frontmatter 缺少 description")
|
|
|
|
|
|
|
|
|
|
|
|
return name, description, data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rewrite_frontmatter_name(content: str, new_name: str) -> str:
|
|
|
|
|
|
match = FRONTMATTER_PATTERN.match(content)
|
|
|
|
|
|
if not match:
|
|
|
|
|
|
raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)")
|
|
|
|
|
|
|
|
|
|
|
|
frontmatter_raw = match.group(1)
|
|
|
|
|
|
body = content[match.end() :]
|
|
|
|
|
|
data = yaml.safe_load(frontmatter_raw)
|
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
|
raise ValueError("SKILL.md frontmatter 必须是对象")
|
|
|
|
|
|
data["name"] = new_name
|
|
|
|
|
|
dumped = yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
|
|
|
|
|
|
return f"---\n{dumped}\n---\n{body}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_zip_paths(zip_file: zipfile.ZipFile) -> None:
|
|
|
|
|
|
for name in zip_file.namelist():
|
|
|
|
|
|
pure = PurePosixPath(name)
|
|
|
|
|
|
if pure.is_absolute():
|
|
|
|
|
|
raise ValueError(f"ZIP 包含不安全绝对路径: {name}")
|
|
|
|
|
|
if ".." in pure.parts:
|
|
|
|
|
|
raise ValueError(f"ZIP 包含路径穿越片段: {name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _generate_available_slug(repo: SkillRepository, base_slug: str) -> str:
|
|
|
|
|
|
root = get_skills_root_dir()
|
|
|
|
|
|
if not await repo.exists_slug(base_slug) and not (root / base_slug).exists():
|
|
|
|
|
|
return base_slug
|
|
|
|
|
|
|
|
|
|
|
|
idx = 2
|
|
|
|
|
|
while True:
|
|
|
|
|
|
candidate = f"{base_slug}-v{idx}"
|
|
|
|
|
|
if not await repo.exists_slug(candidate) and not (root / candidate).exists():
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
idx += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_skill_dir(item: Skill) -> Path:
|
|
|
|
|
|
dir_path = Path(item.dir_path)
|
|
|
|
|
|
if dir_path.is_absolute():
|
|
|
|
|
|
return dir_path
|
|
|
|
|
|
return (Path(sys_config.save_dir) / dir_path).resolve()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_relative_path(skill_dir: Path, relative_path: str, *, allow_root: bool = False) -> tuple[Path, str]:
|
|
|
|
|
|
rel = (relative_path or "").strip().replace("\\", "/")
|
|
|
|
|
|
rel = rel.lstrip("/")
|
|
|
|
|
|
if not rel and not allow_root:
|
|
|
|
|
|
raise ValueError("path 不能为空")
|
|
|
|
|
|
pure = PurePosixPath(rel) if rel else PurePosixPath(".")
|
|
|
|
|
|
if ".." in pure.parts:
|
|
|
|
|
|
raise ValueError("非法路径:不允许上级路径引用")
|
|
|
|
|
|
|
|
|
|
|
|
target = (skill_dir / pure).resolve()
|
|
|
|
|
|
try:
|
|
|
|
|
|
target.relative_to(skill_dir)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise ValueError("非法路径:越界访问被拒绝") from None
|
|
|
|
|
|
|
|
|
|
|
|
return target, rel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_text_path(path: Path) -> bool:
|
|
|
|
|
|
if path.name == "SKILL.md":
|
|
|
|
|
|
return True
|
|
|
|
|
|
suffix = path.suffix.lower()
|
|
|
|
|
|
return suffix in TEXT_FILE_EXTENSIONS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_tree(path: Path, base_dir: Path) -> list[dict[str, Any]]:
|
|
|
|
|
|
children: list[dict[str, Any]] = []
|
|
|
|
|
|
for child in sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
|
|
|
|
rel = child.relative_to(base_dir).as_posix()
|
|
|
|
|
|
if child.is_dir():
|
|
|
|
|
|
children.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": child.name,
|
|
|
|
|
|
"path": rel,
|
|
|
|
|
|
"is_dir": True,
|
|
|
|
|
|
"children": _build_tree(child, base_dir),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
children.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": child.name,
|
|
|
|
|
|
"path": rel,
|
|
|
|
|
|
"is_dir": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return children
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def import_skill_zip(
|
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
|
*,
|
|
|
|
|
|
filename: str,
|
|
|
|
|
|
file_bytes: bytes,
|
|
|
|
|
|
created_by: str | None,
|
|
|
|
|
|
) -> Skill:
|
|
|
|
|
|
if not filename.lower().endswith(".zip"):
|
|
|
|
|
|
raise ValueError("仅支持上传 .zip 文件")
|
|
|
|
|
|
|
|
|
|
|
|
repo = SkillRepository(db)
|
|
|
|
|
|
skills_root = get_skills_root_dir()
|
|
|
|
|
|
|
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root:
|
|
|
|
|
|
temp_root_path = Path(temp_root)
|
|
|
|
|
|
zip_path = temp_root_path / "upload.zip"
|
|
|
|
|
|
extract_dir = temp_root_path / "extract"
|
|
|
|
|
|
stage_dir = temp_root_path / "stage"
|
|
|
|
|
|
extract_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
zip_path.write_bytes(file_bytes)
|
|
|
|
|
|
|
|
|
|
|
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
|
|
|
|
_validate_zip_paths(zf)
|
|
|
|
|
|
zf.extractall(extract_dir)
|
|
|
|
|
|
|
|
|
|
|
|
skill_md_files = list(extract_dir.rglob("SKILL.md"))
|
|
|
|
|
|
if len(skill_md_files) != 1:
|
|
|
|
|
|
raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)")
|
|
|
|
|
|
|
|
|
|
|
|
skill_md_path = skill_md_files[0]
|
|
|
|
|
|
source_skill_dir = skill_md_path.parent
|
|
|
|
|
|
content = skill_md_path.read_text(encoding="utf-8")
|
|
|
|
|
|
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
|
|
|
|
|
|
|
|
|
|
|
|
final_slug = await _generate_available_slug(repo, parsed_name)
|
|
|
|
|
|
final_name = parsed_name
|
|
|
|
|
|
if final_slug != parsed_name:
|
|
|
|
|
|
final_name = final_slug
|
|
|
|
|
|
content = _rewrite_frontmatter_name(content, final_name)
|
|
|
|
|
|
skill_md_path.write_text(content, encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
shutil.copytree(source_skill_dir, stage_dir)
|
|
|
|
|
|
|
|
|
|
|
|
temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
if temp_target.exists():
|
|
|
|
|
|
shutil.rmtree(temp_target)
|
|
|
|
|
|
shutil.move(str(stage_dir), str(temp_target))
|
|
|
|
|
|
|
|
|
|
|
|
final_dir = skills_root / final_slug
|
|
|
|
|
|
if final_dir.exists():
|
|
|
|
|
|
shutil.rmtree(temp_target, ignore_errors=True)
|
|
|
|
|
|
raise ValueError(f"技能目录冲突,请重试: {final_slug}")
|
|
|
|
|
|
temp_target.rename(final_dir)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
item = await repo.create(
|
|
|
|
|
|
slug=final_slug,
|
|
|
|
|
|
name=final_name,
|
|
|
|
|
|
description=parsed_desc,
|
2026-02-22 17:42:37 +08:00
|
|
|
|
tool_dependencies=[],
|
|
|
|
|
|
mcp_dependencies=[],
|
|
|
|
|
|
skill_dependencies=[],
|
2026-02-21 02:48:20 +08:00
|
|
|
|
dir_path=(Path("skills") / final_slug).as_posix(),
|
|
|
|
|
|
created_by=created_by,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
shutil.rmtree(final_dir, ignore_errors=True)
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
items = await repo.list_all()
|
|
|
|
|
|
_set_skill_options_cache(items)
|
|
|
|
|
|
return item
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_skill_or_raise(db: AsyncSession, slug: str) -> Skill:
|
|
|
|
|
|
repo = SkillRepository(db)
|
|
|
|
|
|
item = await repo.get_by_slug(slug)
|
|
|
|
|
|
if not item:
|
|
|
|
|
|
raise ValueError(f"技能 '{slug}' 不存在")
|
|
|
|
|
|
return item
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_skill_tree(db: AsyncSession, slug: str) -> list[dict[str, Any]]:
|
|
|
|
|
|
item = await get_skill_or_raise(db, slug)
|
|
|
|
|
|
skill_dir = _resolve_skill_dir(item)
|
|
|
|
|
|
if not skill_dir.exists() or not skill_dir.is_dir():
|
|
|
|
|
|
raise ValueError(f"技能目录不存在: {item.dir_path}")
|
|
|
|
|
|
return _build_tree(skill_dir, skill_dir)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def read_skill_file(db: AsyncSession, slug: str, relative_path: str) -> dict[str, Any]:
|
|
|
|
|
|
item = await get_skill_or_raise(db, slug)
|
|
|
|
|
|
skill_dir = _resolve_skill_dir(item)
|
|
|
|
|
|
target, rel = _resolve_relative_path(skill_dir, relative_path)
|
|
|
|
|
|
if not target.exists() or not target.is_file():
|
|
|
|
|
|
raise ValueError(f"文件不存在: {relative_path}")
|
|
|
|
|
|
if not _is_text_path(target):
|
|
|
|
|
|
raise ValueError("仅支持读取文本文件")
|
|
|
|
|
|
try:
|
|
|
|
|
|
content = target.read_text(encoding="utf-8")
|
|
|
|
|
|
except UnicodeDecodeError as e:
|
|
|
|
|
|
raise ValueError(f"文件编码不支持(仅支持 UTF-8): {e}") from e
|
|
|
|
|
|
|
|
|
|
|
|
return {"path": rel, "content": content}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def create_skill_node(
|
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
|
*,
|
|
|
|
|
|
slug: str,
|
|
|
|
|
|
relative_path: str,
|
|
|
|
|
|
is_dir: bool,
|
|
|
|
|
|
content: str | None,
|
|
|
|
|
|
updated_by: str | None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
item = await get_skill_or_raise(db, slug)
|
|
|
|
|
|
skill_dir = _resolve_skill_dir(item)
|
|
|
|
|
|
target, _ = _resolve_relative_path(skill_dir, relative_path)
|
|
|
|
|
|
if target.exists():
|
|
|
|
|
|
raise ValueError("目标已存在")
|
|
|
|
|
|
|
|
|
|
|
|
if is_dir:
|
|
|
|
|
|
target.mkdir(parents=True, exist_ok=False)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if not _is_text_path(target):
|
|
|
|
|
|
raise ValueError("仅支持创建文本文件")
|
|
|
|
|
|
|
|
|
|
|
|
parsed_name: str | None = None
|
|
|
|
|
|
parsed_desc: str | None = None
|
|
|
|
|
|
if target.name == "SKILL.md" and target.parent == skill_dir:
|
|
|
|
|
|
parsed_name, parsed_desc, _ = _parse_skill_markdown(content or "")
|
|
|
|
|
|
if parsed_name != item.slug:
|
|
|
|
|
|
raise ValueError("SKILL.md frontmatter.name 必须与 skill slug 一致")
|
|
|
|
|
|
|
|
|
|
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
target.write_text(content or "", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
if parsed_name is not None and parsed_desc is not None:
|
|
|
|
|
|
repo = SkillRepository(db)
|
|
|
|
|
|
await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by)
|
|
|
|
|
|
_set_skill_options_cache(await repo.list_all())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def update_skill_file(
|
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
|
*,
|
|
|
|
|
|
slug: str,
|
|
|
|
|
|
relative_path: str,
|
|
|
|
|
|
content: str,
|
|
|
|
|
|
updated_by: str | None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
item = await get_skill_or_raise(db, slug)
|
|
|
|
|
|
skill_dir = _resolve_skill_dir(item)
|
|
|
|
|
|
target, _ = _resolve_relative_path(skill_dir, relative_path)
|
|
|
|
|
|
if not target.exists() or not target.is_file():
|
|
|
|
|
|
raise ValueError("文件不存在")
|
|
|
|
|
|
if not _is_text_path(target):
|
|
|
|
|
|
raise ValueError("仅支持编辑文本文件")
|
|
|
|
|
|
|
|
|
|
|
|
parsed_name = None
|
|
|
|
|
|
parsed_desc = None
|
|
|
|
|
|
if target.name == "SKILL.md" and target.parent == skill_dir:
|
|
|
|
|
|
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
|
|
|
|
|
|
if parsed_name != item.slug:
|
|
|
|
|
|
raise ValueError("SKILL.md frontmatter.name 必须与 skill slug 一致")
|
|
|
|
|
|
|
|
|
|
|
|
target.write_text(content, encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
if parsed_name is not None and parsed_desc is not None:
|
|
|
|
|
|
repo = SkillRepository(db)
|
|
|
|
|
|
await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by)
|
|
|
|
|
|
_set_skill_options_cache(await repo.list_all())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def delete_skill_node(db: AsyncSession, *, slug: str, relative_path: str) -> None:
|
|
|
|
|
|
item = await get_skill_or_raise(db, slug)
|
|
|
|
|
|
skill_dir = _resolve_skill_dir(item)
|
|
|
|
|
|
target, rel = _resolve_relative_path(skill_dir, relative_path, allow_root=False)
|
|
|
|
|
|
if not target.exists():
|
|
|
|
|
|
raise ValueError("目标不存在")
|
|
|
|
|
|
|
|
|
|
|
|
if rel == "SKILL.md":
|
|
|
|
|
|
raise ValueError("不允许删除根目录 SKILL.md")
|
|
|
|
|
|
|
|
|
|
|
|
if target.is_dir():
|
|
|
|
|
|
shutil.rmtree(target)
|
|
|
|
|
|
else:
|
|
|
|
|
|
target.unlink()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def export_skill_zip(db: AsyncSession, slug: str) -> tuple[str, str]:
|
|
|
|
|
|
item = await get_skill_or_raise(db, slug)
|
|
|
|
|
|
skill_dir = _resolve_skill_dir(item)
|
|
|
|
|
|
if not skill_dir.exists() or not skill_dir.is_dir():
|
|
|
|
|
|
raise ValueError("技能目录不存在")
|
|
|
|
|
|
|
|
|
|
|
|
fd, export_path = tempfile.mkstemp(prefix=f"skill-{slug}-", suffix=".zip")
|
|
|
|
|
|
Path(export_path).unlink(missing_ok=True)
|
|
|
|
|
|
export_file = Path(export_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with zipfile.ZipFile(export_file, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
|
|
|
|
for p in skill_dir.rglob("*"):
|
|
|
|
|
|
arcname = Path(slug) / p.relative_to(skill_dir)
|
|
|
|
|
|
zf.write(p, arcname.as_posix())
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
export_file.unlink(missing_ok=True)
|
|
|
|
|
|
raise
|
|
|
|
|
|
return export_path, f"{slug}.zip"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def delete_skill(db: AsyncSession, *, slug: str) -> None:
|
|
|
|
|
|
repo = SkillRepository(db)
|
|
|
|
|
|
item = await repo.get_by_slug(slug)
|
|
|
|
|
|
if not item:
|
|
|
|
|
|
raise ValueError(f"技能 '{slug}' 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
skill_dir = _resolve_skill_dir(item)
|
|
|
|
|
|
trash_dir: Path | None = None
|
|
|
|
|
|
|
|
|
|
|
|
if skill_dir.exists():
|
|
|
|
|
|
trash_dir = skill_dir.with_name(f".deleted-{slug}-{uuid.uuid4().hex[:8]}")
|
|
|
|
|
|
skill_dir.rename(trash_dir)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
await repo.delete(item)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
if trash_dir and trash_dir.exists():
|
|
|
|
|
|
trash_dir.rename(skill_dir)
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
if trash_dir and trash_dir.exists():
|
|
|
|
|
|
shutil.rmtree(trash_dir, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
|
|
_set_skill_options_cache(await repo.list_all())
|