feat(skill): 添加远程技能安装服务及相关功能更新
- 新增 remote_skill_install_service.py 支持远程技能安装 - 更新 skill_service.py 增强技能管理功能 - 更新 skill_router.py 添加相关路由 - 更新前端组件 SkillsManagerComponent.vue - 更新 ExtensionsView.vue - 添加对应单元测试 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8ba4f7050d
commit
a828d7154b
190
backend/package/yuxi/services/remote_skill_install_service.py
Normal file
190
backend/package/yuxi/services/remote_skill_install_service.py
Normal file
@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from yuxi.services.skill_service import import_skill_dir, is_valid_skill_slug
|
||||
|
||||
ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
||||
CONTROL_SEQUENCE_RE = re.compile(r"\x1B\][^\x07]*(?:\x07|\x1B\\)|\x1B[\(\)][A-Za-z0-9]")
|
||||
SKILL_LINE_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
CLI_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
def _normalize_source(source: str) -> str:
|
||||
value = str(source or "").strip()
|
||||
if not value:
|
||||
raise ValueError("source 不能为空")
|
||||
if any(ch in value for ch in ("\n", "\r", "\x00")):
|
||||
raise ValueError("source 包含非法字符")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_skill_name(skill: str) -> str:
|
||||
value = str(skill or "").strip()
|
||||
if not is_valid_skill_slug(value):
|
||||
raise ValueError("skill 名称不合法")
|
||||
return value
|
||||
|
||||
|
||||
def _clean_cli_output(output: str) -> list[str]:
|
||||
cleaned = ANSI_ESCAPE_RE.sub("", output or "")
|
||||
cleaned = CONTROL_SEQUENCE_RE.sub("", cleaned)
|
||||
cleaned = cleaned.replace("\r", "\n")
|
||||
normalized_lines: list[str] = []
|
||||
for line in cleaned.splitlines():
|
||||
stripped = line.strip()
|
||||
stripped = re.sub(r"^[│┌└◇◒◐◓◑■●]+\s*", "", stripped)
|
||||
normalized_lines.append(stripped.strip())
|
||||
return normalized_lines
|
||||
|
||||
|
||||
def _parse_available_skills(output: str) -> list[dict[str, str]]:
|
||||
lines = _clean_cli_output(output)
|
||||
items: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
collecting = False
|
||||
|
||||
for idx, line in enumerate(lines):
|
||||
if not collecting:
|
||||
if "Available Skills" in line:
|
||||
collecting = True
|
||||
continue
|
||||
|
||||
if not line:
|
||||
continue
|
||||
if "Use --skill " in line:
|
||||
break
|
||||
if not SKILL_LINE_RE.fullmatch(line):
|
||||
continue
|
||||
if line in seen:
|
||||
continue
|
||||
|
||||
description = ""
|
||||
next_index = idx + 1
|
||||
while next_index < len(lines):
|
||||
next_line = lines[next_index]
|
||||
next_index += 1
|
||||
if not next_line:
|
||||
continue
|
||||
if "Use --skill " in next_line:
|
||||
break
|
||||
if SKILL_LINE_RE.fullmatch(next_line):
|
||||
break
|
||||
if next_line and next_line[0].isalpha():
|
||||
description = next_line
|
||||
break
|
||||
|
||||
seen.add(line)
|
||||
items.append({"name": line, "description": description})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
async def _run_skills_cli(
|
||||
args: list[str],
|
||||
*,
|
||||
env: dict[str, str],
|
||||
cwd: str,
|
||||
) -> str:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=CLI_TIMEOUT_SECONDS)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.communicate()
|
||||
raise ValueError("skills CLI 执行超时") from None
|
||||
|
||||
output = (stdout or b"").decode("utf-8", errors="replace")
|
||||
error_output = (stderr or b"").decode("utf-8", errors="replace")
|
||||
combined = "\n".join(part for part in [output.strip(), error_output.strip()] if part)
|
||||
if process.returncode != 0:
|
||||
raise ValueError(combined or "skills CLI 执行失败")
|
||||
return combined
|
||||
|
||||
|
||||
async def list_remote_skills(source: str) -> list[dict[str, str]]:
|
||||
normalized_source = _normalize_source(source)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".remote-skills-") as temp_home:
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = temp_home
|
||||
workdir = str(Path(temp_home) / "workspace")
|
||||
Path(workdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output = await _run_skills_cli(
|
||||
["npx", "-y", "skills", "add", normalized_source, "--list"],
|
||||
env=env,
|
||||
cwd=workdir,
|
||||
)
|
||||
|
||||
skills = _parse_available_skills(output)
|
||||
if not skills:
|
||||
raise ValueError("未发现可安装的 skills")
|
||||
return skills
|
||||
|
||||
|
||||
async def install_remote_skill(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source: str,
|
||||
skill: str,
|
||||
created_by: str | None,
|
||||
) -> object:
|
||||
normalized_source = _normalize_source(source)
|
||||
normalized_skill = _normalize_skill_name(skill)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".remote-skills-") as temp_home:
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = temp_home
|
||||
workdir = str(Path(temp_home) / "workspace")
|
||||
Path(workdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
available_skills = _parse_available_skills(
|
||||
await _run_skills_cli(
|
||||
["npx", "-y", "skills", "add", normalized_source, "--list"],
|
||||
env=env,
|
||||
cwd=workdir,
|
||||
)
|
||||
)
|
||||
available_names = {item["name"] for item in available_skills}
|
||||
if normalized_skill not in available_names:
|
||||
raise ValueError(f"远程仓库中不存在 skill: {normalized_skill}")
|
||||
|
||||
await _run_skills_cli(
|
||||
[
|
||||
"npx",
|
||||
"-y",
|
||||
"skills",
|
||||
"add",
|
||||
normalized_source,
|
||||
"--skill",
|
||||
normalized_skill,
|
||||
"-g",
|
||||
"-y",
|
||||
"--copy",
|
||||
],
|
||||
env=env,
|
||||
cwd=workdir,
|
||||
)
|
||||
|
||||
installed_dir = Path(temp_home) / ".agents" / "skills" / normalized_skill
|
||||
if not installed_dir.exists() or not installed_dir.is_dir():
|
||||
raise ValueError("skills CLI 未生成预期的技能目录")
|
||||
|
||||
return await import_skill_dir(
|
||||
db,
|
||||
source_dir=installed_dir,
|
||||
created_by=created_by,
|
||||
)
|
||||
@ -429,6 +429,63 @@ async def _generate_available_slug(repo: SkillRepository, base_slug: str) -> str
|
||||
idx += 1
|
||||
|
||||
|
||||
async def _import_skill_dir_impl(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source_skill_dir: Path,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
repo = SkillRepository(db)
|
||||
skills_root = get_skills_root_dir()
|
||||
|
||||
skill_md_path = source_skill_dir / "SKILL.md"
|
||||
if not skill_md_path.exists() or not skill_md_path.is_file():
|
||||
raise ValueError("技能目录缺少根级 SKILL.md")
|
||||
|
||||
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
|
||||
with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root:
|
||||
temp_root_path = Path(temp_root)
|
||||
stage_dir = temp_root_path / "stage"
|
||||
shutil.copytree(source_skill_dir, stage_dir)
|
||||
|
||||
if final_slug != parsed_name:
|
||||
final_name = final_slug
|
||||
content = _rewrite_frontmatter_name(content, final_name)
|
||||
(stage_dir / "SKILL.md").write_text(content, encoding="utf-8")
|
||||
|
||||
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,
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
dir_path=(Path("skills") / final_slug).as_posix(),
|
||||
created_by=created_by,
|
||||
)
|
||||
except Exception:
|
||||
shutil.rmtree(final_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def _resolve_skill_dir(item: Skill) -> Path:
|
||||
dir_path = Path(item.dir_path)
|
||||
if dir_path.is_absolute():
|
||||
@ -498,13 +555,11 @@ async def import_skill_zip(
|
||||
if not is_zip_upload and not is_skill_md_upload:
|
||||
raise ValueError("仅支持上传 .zip 或 SKILL.md 文件")
|
||||
|
||||
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)
|
||||
extract_dir = temp_root_path / "extract"
|
||||
stage_dir = temp_root_path / "stage"
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
if is_zip_upload:
|
||||
zip_path = temp_root_path / "upload.zip"
|
||||
@ -525,45 +580,27 @@ async def import_skill_zip(
|
||||
skill_md_path = source_skill_dir / "SKILL.md"
|
||||
skill_md_path.write_bytes(file_bytes)
|
||||
|
||||
content = skill_md_path.read_text(encoding="utf-8")
|
||||
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
|
||||
return await _import_skill_dir_impl(
|
||||
db,
|
||||
source_skill_dir=source_skill_dir,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
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,
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
dir_path=(Path("skills") / final_slug).as_posix(),
|
||||
created_by=created_by,
|
||||
)
|
||||
except Exception:
|
||||
shutil.rmtree(final_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return item
|
||||
async def import_skill_dir(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source_dir: Path | str,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
source_skill_dir = Path(source_dir).resolve()
|
||||
if not source_skill_dir.exists() or not source_skill_dir.is_dir():
|
||||
raise ValueError("技能目录不存在")
|
||||
return await _import_skill_dir_impl(
|
||||
db,
|
||||
source_skill_dir=source_skill_dir,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
|
||||
async def get_skill_or_raise(db: AsyncSession, slug: str) -> Skill:
|
||||
|
||||
@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user
|
||||
from yuxi.services.remote_skill_install_service import install_remote_skill, list_remote_skills
|
||||
from yuxi.services.skill_service import (
|
||||
BuiltinSkillUpdateConflictError,
|
||||
create_skill_node,
|
||||
@ -54,6 +55,14 @@ class BuiltinSkillUpdateRequest(BaseModel):
|
||||
force: bool = Field(False, description="是否强制覆盖本地已安装内容")
|
||||
|
||||
|
||||
class RemoteSkillSourceRequest(BaseModel):
|
||||
source: str = Field(..., description="skills 仓库来源,如 owner/repo 或 GitHub URL")
|
||||
|
||||
|
||||
class RemoteSkillInstallRequest(RemoteSkillSourceRequest):
|
||||
skill: str = Field(..., description="需要安装的 skill 名称")
|
||||
|
||||
|
||||
def _raise_from_value_error(e: ValueError) -> None:
|
||||
message = str(e)
|
||||
status_code = 404 if "不存在" in message else 400
|
||||
@ -201,6 +210,47 @@ async def import_skill_route(
|
||||
raise HTTPException(status_code=500, detail="导入技能失败")
|
||||
|
||||
|
||||
@skills.post("/remote/list")
|
||||
async def list_remote_skills_route(
|
||||
payload: RemoteSkillSourceRequest,
|
||||
_current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
try:
|
||||
return {"success": True, "data": await list_remote_skills(payload.source)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list remote skills from '{payload.source}': {e}")
|
||||
raise HTTPException(status_code=500, detail="获取远程 skills 列表失败")
|
||||
|
||||
|
||||
@skills.post("/remote/install")
|
||||
async def install_remote_skill_route(
|
||||
payload: RemoteSkillInstallRequest,
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
item = await install_remote_skill(
|
||||
db,
|
||||
source=payload.source,
|
||||
skill=payload.skill,
|
||||
created_by=current_user.username,
|
||||
)
|
||||
return {"success": True, "data": item.to_dict()}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to install remote skill '{payload.skill}' from '{payload.source}': {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="安装远程 skill 失败")
|
||||
|
||||
|
||||
@skills.get("/{slug}/tree")
|
||||
async def get_skill_tree_route(
|
||||
slug: str,
|
||||
|
||||
@ -194,3 +194,52 @@ def test_update_skill_dependencies_route(monkeypatch):
|
||||
assert captured["mcp_dependencies"] == ["mcp-a"]
|
||||
assert captured["skill_dependencies"] == ["other-skill"]
|
||||
assert captured["updated_by"] == "root"
|
||||
|
||||
|
||||
def test_list_remote_skills_route(monkeypatch):
|
||||
async def fake_list_remote_skills(source: str):
|
||||
assert source == "anthropics/skills"
|
||||
return [{"name": "frontend-design", "description": "demo"}]
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.list_remote_skills", fake_list_remote_skills)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/system/skills/remote/list", json={"source": "anthropics/skills"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
payload = resp.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"] == [{"name": "frontend-design", "description": "demo"}]
|
||||
|
||||
|
||||
def test_install_remote_skill_route(monkeypatch):
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
async def fake_install_remote_skill(_db, *, source, skill, created_by):
|
||||
captured["source"] = source
|
||||
captured["skill"] = skill
|
||||
captured["created_by"] = created_by
|
||||
return Skill(
|
||||
slug="frontend-design",
|
||||
name="frontend-design",
|
||||
description="demo skill",
|
||||
dir_path="skills/frontend-design",
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.install_remote_skill", fake_install_remote_skill)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/system/skills/remote/install",
|
||||
json={"source": "anthropics/skills", "skill": "frontend-design"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
payload = resp.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"]["slug"] == "frontend-design"
|
||||
assert captured["source"] == "anthropics/skills"
|
||||
assert captured["skill"] == "frontend-design"
|
||||
assert captured["created_by"] == "root"
|
||||
|
||||
145
backend/test/unit/services/test_remote_skill_install_service.py
Normal file
145
backend/test/unit/services/test_remote_skill_install_service.py
Normal file
@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.services import remote_skill_install_service as svc
|
||||
|
||||
|
||||
def test_parse_available_skills_from_cli_output() -> None:
|
||||
output = """
|
||||
\x1b[38;5;250m███████╗\x1b[0m
|
||||
◇ Available Skills
|
||||
Claude Api
|
||||
|
||||
claude-api
|
||||
|
||||
Build apps with the Claude API.
|
||||
|
||||
Example Skills
|
||||
|
||||
frontend-design
|
||||
|
||||
Create distinctive frontend interfaces.
|
||||
|
||||
└ Use --skill <name> to install specific skills
|
||||
"""
|
||||
|
||||
skills = svc._parse_available_skills(output)
|
||||
|
||||
assert skills == [
|
||||
{"name": "claude-api", "description": "Build apps with the Claude API."},
|
||||
{"name": "frontend-design", "description": "Create distinctive frontend interfaces."},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_remote_skills_uses_isolated_home(monkeypatch: pytest.MonkeyPatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str:
|
||||
captured["args"] = args
|
||||
captured["home"] = env["HOME"]
|
||||
captured["cwd"] = cwd
|
||||
return """
|
||||
◇ Available Skills
|
||||
|
||||
frontend-design
|
||||
|
||||
Create distinctive frontend interfaces.
|
||||
|
||||
└ Use --skill <name> to install specific skills
|
||||
"""
|
||||
|
||||
monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli)
|
||||
|
||||
items = await svc.list_remote_skills("anthropics/skills")
|
||||
|
||||
assert items == [{"name": "frontend-design", "description": "Create distinctive frontend interfaces."}]
|
||||
assert captured["args"] == ["npx", "-y", "skills", "add", "anthropics/skills", "--list"]
|
||||
assert str(captured["cwd"]).startswith(str(captured["home"]))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_remote_skill_imports_from_cli_output_dir(monkeypatch: pytest.MonkeyPatch):
|
||||
calls: list[tuple[list[str], str]] = []
|
||||
|
||||
async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str:
|
||||
calls.append((args, env["HOME"]))
|
||||
home = Path(env["HOME"])
|
||||
if "--list" in args:
|
||||
return """
|
||||
◇ Available Skills
|
||||
|
||||
frontend-design
|
||||
|
||||
Create distinctive frontend interfaces.
|
||||
|
||||
└ Use --skill <name> to install specific skills
|
||||
"""
|
||||
skill_dir = home / ".agents" / "skills" / "frontend-design"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: frontend-design\ndescription: demo\n---\n# Demo\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return "installed"
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_import_skill_dir(_db, *, source_dir, created_by):
|
||||
captured["source_dir"] = Path(source_dir)
|
||||
captured["created_by"] = created_by
|
||||
return {"slug": "frontend-design"}
|
||||
|
||||
monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli)
|
||||
monkeypatch.setattr(svc, "import_skill_dir", fake_import_skill_dir)
|
||||
|
||||
item = await svc.install_remote_skill(
|
||||
None,
|
||||
source="anthropics/skills",
|
||||
skill="frontend-design",
|
||||
created_by="root",
|
||||
)
|
||||
|
||||
assert item == {"slug": "frontend-design"}
|
||||
assert calls[0][0] == ["npx", "-y", "skills", "add", "anthropics/skills", "--list"]
|
||||
assert calls[1][0] == [
|
||||
"npx",
|
||||
"-y",
|
||||
"skills",
|
||||
"add",
|
||||
"anthropics/skills",
|
||||
"--skill",
|
||||
"frontend-design",
|
||||
"-g",
|
||||
"-y",
|
||||
"--copy",
|
||||
]
|
||||
assert captured["source_dir"] == Path(calls[1][1]) / ".agents" / "skills" / "frontend-design"
|
||||
assert captured["created_by"] == "root"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_remote_skill_rejects_missing_remote_skill(monkeypatch: pytest.MonkeyPatch):
|
||||
async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str:
|
||||
return """
|
||||
◇ Available Skills
|
||||
|
||||
other-skill
|
||||
|
||||
Description
|
||||
|
||||
└ Use --skill <name> to install specific skills
|
||||
"""
|
||||
|
||||
monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli)
|
||||
|
||||
with pytest.raises(ValueError, match="不存在 skill"):
|
||||
await svc.install_remote_skill(
|
||||
None,
|
||||
source="anthropics/skills",
|
||||
skill="frontend-design",
|
||||
created_by="root",
|
||||
)
|
||||
@ -221,6 +221,20 @@ async def test_import_skill_md_creates_single_file_skill(
|
||||
assert (tmp_path / "skills" / "demo" / "SKILL.md").read_text(encoding="utf-8") == skill_md
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_skill_dir_requires_root_skill_md(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
|
||||
source_dir = tmp_path / "source-skill"
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with pytest.raises(ValueError, match="根级 SKILL.md"):
|
||||
await svc.import_skill_dir(
|
||||
None,
|
||||
source_dir=source_dir,
|
||||
created_by="root",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
|
||||
|
||||
@ -42,11 +42,12 @@ Skills 系统采用「文件系统存内容,数据库存索引」的分离架
|
||||
|
||||
## 创建方式
|
||||
|
||||
系统提供三种方式创建 Skills:
|
||||
系统提供四种方式创建 Skills:
|
||||
|
||||
1. **ZIP 导入(推荐)**:将 Skill 目录打包成 ZIP,通过管理界面上传导入
|
||||
2. **在线创建**:通过 Skills 管理页面在线创建目录和文件
|
||||
3. **手动导入**:直接操作数据库(不推荐,需要手动同步文件系统和数据库)
|
||||
3. **远程仓库安装**:在 Skills 管理页面填写 skills 仓库地址和 skill 名称,由后端调用 `npx skills` 下载后再导入系统
|
||||
4. **手动导入**:直接操作数据库(不推荐,需要手动同步文件系统和数据库)
|
||||
|
||||
## Skills 来源
|
||||
|
||||
@ -102,7 +103,7 @@ description: 这是一个用于处理特定任务的技能
|
||||
|
||||
### 导入 Skill
|
||||
|
||||
有两种方式可以导入 Skill:
|
||||
有三种方式可以导入 Skill:
|
||||
|
||||
**方式一:通过 ZIP 包导入(推荐)**
|
||||
|
||||
@ -122,6 +123,21 @@ description: 这是一个用于处理特定任务的技能
|
||||
- 在线编辑文本文件(支持 .md、.py、.js、.json 等格式)
|
||||
- 直接在网页上编写 SKILL.md 内容
|
||||
|
||||
**方式三:从远程 skills 仓库安装**
|
||||
|
||||
1. 在 Skills 管理页面的“远程安装”面板中填写仓库来源,例如 `anthropics/skills` 或完整 GitHub URL
|
||||
2. 点击“查看可安装 Skills”获取该仓库中可发现的 skills 列表
|
||||
3. 选择或输入目标 skill 名称后点击“安装”
|
||||
|
||||
系统会在后端:
|
||||
- 调用 `npx skills add <source> --list` 校验来源并发现可安装的 skills
|
||||
- 使用隔离的临时 `HOME` 执行 `npx skills add <source> --skill <name> -g -y --copy`
|
||||
- 从临时目录中提取对应 skill,再按现有导入流程写入 `/app/saves/skills` 与数据库
|
||||
|
||||
::: tip 远程安装不会把 ~/.agents/skills 作为系统主存储
|
||||
远程安装只把 `skills.sh` CLI 作为“下载器”使用。Yuxi 仍然以 `/app/saves/skills + skills 表` 作为正式来源,这样才能与现有的权限、线程可见性和沙盒挂载机制保持一致。
|
||||
:::
|
||||
|
||||
## 依赖系统
|
||||
|
||||
Skills 之间可以建立依赖关系,形成一个松耦合的技能网络。
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
- 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。
|
||||
- 收紧文件系统安全边界:viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2,并移除 skill frontmatter 解析中的正则回溯风险。
|
||||
- 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能
|
||||
- 新增 Skills 远程安装能力:Skills 管理页支持填写 `owner/repo` 或 GitHub URL,后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill,再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -18,6 +18,14 @@ export const importSkillZip = async (file) => {
|
||||
return apiSuperAdminPost(`${BASE_URL}/import`, formData)
|
||||
}
|
||||
|
||||
export const listRemoteSkills = async (source) => {
|
||||
return apiSuperAdminPost(`${BASE_URL}/remote/list`, { source })
|
||||
}
|
||||
|
||||
export const installRemoteSkill = async (payload) => {
|
||||
return apiSuperAdminPost(`${BASE_URL}/remote/install`, payload)
|
||||
}
|
||||
|
||||
export const getSkillDependencyOptions = async () => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/dependency-options`)
|
||||
}
|
||||
@ -73,6 +81,8 @@ export const deleteSkill = async (slug) => {
|
||||
export const skillApi = {
|
||||
listSkills,
|
||||
importSkillZip,
|
||||
listRemoteSkills,
|
||||
installRemoteSkill,
|
||||
getSkillDependencyOptions,
|
||||
listBuiltinSkills,
|
||||
installBuiltinSkill,
|
||||
|
||||
@ -352,6 +352,62 @@
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="remoteInstallModalVisible"
|
||||
title="远程安装 Skill"
|
||||
:footer="null"
|
||||
width="560px"
|
||||
>
|
||||
<div class="remote-install-panel modal-mode">
|
||||
<div class="panel-header-text">
|
||||
<span class="title">基于 skills.sh 的能力拉取并导入到当前系统</span>
|
||||
<span class="desc">
|
||||
支持 `owner/repo` 或完整 GitHub URL,可前往
|
||||
<a href="https://skills.sh/" target="_blank" rel="noopener noreferrer">skills.sh</a>
|
||||
查询可用 skills
|
||||
</span>
|
||||
</div>
|
||||
<a-form layout="vertical" class="remote-install-form">
|
||||
<a-form-item label="来源仓库">
|
||||
<a-input
|
||||
v-model:value="remoteInstallForm.source"
|
||||
placeholder="anthropics/skills 或 GitHub URL"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Skill 名称">
|
||||
<a-auto-complete
|
||||
v-model:value="remoteInstallForm.skill"
|
||||
:options="filteredRemoteSkillOptions"
|
||||
placeholder="frontend-design"
|
||||
allow-clear
|
||||
>
|
||||
<a-input />
|
||||
</a-auto-complete>
|
||||
</a-form-item>
|
||||
<div class="remote-install-actions">
|
||||
<a-button
|
||||
:loading="listingRemoteSkills"
|
||||
:disabled="installingRemoteSkill"
|
||||
@click="handleListRemoteSkills"
|
||||
>
|
||||
查看可安装 Skills
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="installingRemoteSkill"
|
||||
:disabled="listingRemoteSkills"
|
||||
@click="handleInstallRemoteSkill"
|
||||
>
|
||||
安装
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="remoteSkillOptions.length" class="remote-skill-summary">
|
||||
共发现 {{ remoteSkillOptions.length }} 个 skills,可按输入内容筛选候选项。
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -391,6 +447,8 @@ const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
||||
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const listingRemoteSkills = ref(false)
|
||||
const installingRemoteSkill = ref(false)
|
||||
const savingFile = ref(false)
|
||||
const creatingNode = ref(false)
|
||||
const savingDependencies = ref(false)
|
||||
@ -410,7 +468,13 @@ const fileContent = ref('')
|
||||
const originalFileContent = ref('')
|
||||
|
||||
const createModalVisible = ref(false)
|
||||
const remoteInstallModalVisible = ref(false)
|
||||
const createForm = reactive({ path: '', isDir: false, content: '' })
|
||||
const remoteInstallForm = reactive({
|
||||
source: 'https://github.com/anthropics/skills',
|
||||
skill: ''
|
||||
})
|
||||
const remoteSkillOptions = ref([])
|
||||
const dependencyOptions = reactive({ tools: [], mcps: [], skills: [] })
|
||||
const dependencyForm = reactive({
|
||||
tool_dependencies: [],
|
||||
@ -521,6 +585,15 @@ const skillDependencyOptions = computed(() =>
|
||||
.filter((s) => s !== currentSkill.value?.slug)
|
||||
.map((i) => ({ label: i, value: i }))
|
||||
)
|
||||
const filteredRemoteSkillOptions = computed(() => {
|
||||
const keyword = remoteInstallForm.skill.trim().toLowerCase()
|
||||
return remoteSkillOptions.value
|
||||
.filter((item) => !keyword || item.name.toLowerCase().includes(keyword))
|
||||
.map((item) => ({
|
||||
value: item.name,
|
||||
label: item.description ? `${item.name} - ${item.description}` : item.name
|
||||
}))
|
||||
})
|
||||
|
||||
const normalizeTree = (nodes) =>
|
||||
(nodes || []).map((node) => ({
|
||||
@ -858,6 +931,63 @@ const handleImportUpload = async ({ file, onSuccess, onError }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleListRemoteSkills = async () => {
|
||||
const source = remoteInstallForm.source.trim()
|
||||
if (!source) {
|
||||
message.warning('请输入来源仓库')
|
||||
return
|
||||
}
|
||||
listingRemoteSkills.value = true
|
||||
try {
|
||||
const result = await skillApi.listRemoteSkills(source)
|
||||
remoteSkillOptions.value = result?.data || []
|
||||
if (!remoteSkillOptions.value.length) {
|
||||
message.warning('未发现可安装的 Skills')
|
||||
return
|
||||
}
|
||||
if (!remoteInstallForm.skill) {
|
||||
remoteInstallForm.skill = remoteSkillOptions.value[0]?.name || ''
|
||||
}
|
||||
message.success(`已发现 ${remoteSkillOptions.value.length} 个 Skills`)
|
||||
} catch (error) {
|
||||
message.error(error?.response?.data?.detail || error.message || '获取远程 Skills 失败')
|
||||
} finally {
|
||||
listingRemoteSkills.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleInstallRemoteSkill = async () => {
|
||||
const source = remoteInstallForm.source.trim()
|
||||
const skill = remoteInstallForm.skill.trim()
|
||||
if (!source || !skill) {
|
||||
message.warning('请填写来源仓库和 Skill 名称')
|
||||
return
|
||||
}
|
||||
installingRemoteSkill.value = true
|
||||
try {
|
||||
const result = await skillApi.installRemoteSkill({ source, skill })
|
||||
const installed = result?.data
|
||||
remoteInstallForm.skill = installed?.slug || skill
|
||||
await fetchSkills()
|
||||
if (installed?.slug) {
|
||||
const record =
|
||||
skills.value.find((item) => item.slug === installed.slug) ||
|
||||
builtinSkills.value.find((item) => item.slug === installed.slug)
|
||||
if (record) await selectSkill(record)
|
||||
}
|
||||
remoteInstallModalVisible.value = false
|
||||
message.success('远程 Skill 安装成功')
|
||||
} catch (error) {
|
||||
message.error(error?.response?.data?.detail || error.message || '远程 Skill 安装失败')
|
||||
} finally {
|
||||
installingRemoteSkill.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openRemoteInstallModal = () => {
|
||||
remoteInstallModalVisible.value = true
|
||||
}
|
||||
|
||||
const saveDependencies = async () => {
|
||||
if (!currentSkill.value || !isInstalledSkill.value) return
|
||||
savingDependencies.value = true
|
||||
@ -886,7 +1016,8 @@ onMounted(fetchSkills)
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
fetchSkills,
|
||||
handleImportUpload
|
||||
handleImportUpload,
|
||||
openRemoteInstallModal
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -913,6 +1044,62 @@ defineExpose({
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.remote-install-panel {
|
||||
background: linear-gradient(180deg, var(--gray-0) 0%, var(--gray-50) 100%);
|
||||
border: 1px solid @border-color;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
|
||||
&.modal-mode {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.panel-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
.remote-install-form {
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.remote-install-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remote-skill-hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.remote-skill-summary {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文件 tree */
|
||||
.tree-container {
|
||||
width: 240px;
|
||||
|
||||
@ -10,6 +10,14 @@
|
||||
<div class="header-actions">
|
||||
<!-- Skills Tab 的按钮 -->
|
||||
<template v-if="activeTab === 'skills'">
|
||||
<a-button
|
||||
@click="handleOpenRemoteInstall"
|
||||
:disabled="skillsLoading || skillsImporting"
|
||||
class="lucide-icon-btn"
|
||||
>
|
||||
<Computer :size="14" />
|
||||
<span>远程安装</span>
|
||||
</a-button>
|
||||
<a-upload
|
||||
accept=".zip,.md"
|
||||
:show-upload-list="false"
|
||||
@ -90,7 +98,7 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Upload, RotateCw, Plus } from 'lucide-vue-next'
|
||||
import { Upload, RotateCw, Plus, Computer } from 'lucide-vue-next'
|
||||
import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue'
|
||||
import ToolsManagerComponent from '@/components/ToolsManagerComponent.vue'
|
||||
import McpServersComponent from '@/components/McpServersComponent.vue'
|
||||
@ -154,6 +162,12 @@ const handleSkillsRefresh = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenRemoteInstall = () => {
|
||||
if (skillsRef.value?.openRemoteInstallModal) {
|
||||
skillsRef.value.openRemoteInstallModal()
|
||||
}
|
||||
}
|
||||
|
||||
// Tools 事件处理
|
||||
const handleToolsRefresh = () => {
|
||||
if (toolsRef.value?.fetchTools) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user