224 lines
9.1 KiB
Python
224 lines
9.1 KiB
Python
"""SkillInstallTool - Agent 可调用的技能安装工具"""
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import re
|
||
from typing import Callable, Awaitable
|
||
|
||
from agentkit.tools.base import Tool
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Skill names are used to construct filesystem paths — reject anything
|
||
# that could escape the skills directory (path traversal).
|
||
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$")
|
||
|
||
|
||
class SkillInstallTool(Tool):
|
||
"""技能安装工具
|
||
|
||
让 Agent 可以通过正确的命令安装技能,而不是用 shell 执行 npm install。
|
||
使用 `npx skills install <owner/repo@skill>` 安装技能,然后注册到 skill_registry。
|
||
|
||
Usage:
|
||
tool = SkillInstallTool()
|
||
result = await tool.execute(name="find-skills", source="vercel-labs/skills@find-skills")
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
name: str = "skill_install",
|
||
description: str = (
|
||
"安装 Agent 技能包。使用 npx skills install 安装指定技能,不要用 npm install。"
|
||
"重要:安装前应先用 skill_search 工具搜索确认技能名称和来源(source)。"
|
||
"如果用户只提供了模糊名称,先用 skill_search 搜索,再根据搜索结果安装。"
|
||
),
|
||
input_schema: dict[str, object] | None = None,
|
||
output_schema: dict[str, object] | None = None,
|
||
version: str = "1.0.0",
|
||
tags: list[str] | None = None,
|
||
confirm_callback: Callable[[str], Awaitable[bool]] | None = None,
|
||
skill_registry=None,
|
||
tool_registry=None,
|
||
):
|
||
schema = input_schema or {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {
|
||
"type": "string",
|
||
"description": "技能名称,如 find-skills",
|
||
},
|
||
"source": {
|
||
"type": "string",
|
||
"description": "技能来源,格式为 owner/repo@skill,如 vercel-labs/skills@find-skills。如果不提供则用 name 搜索",
|
||
},
|
||
},
|
||
"required": ["name"],
|
||
}
|
||
super().__init__(
|
||
name=name,
|
||
description=description,
|
||
input_schema=schema,
|
||
output_schema=output_schema,
|
||
version=version,
|
||
tags=tags,
|
||
)
|
||
self._confirm_callback = confirm_callback
|
||
self._skill_registry = skill_registry
|
||
self._tool_registry = tool_registry
|
||
|
||
async def execute(self, **kwargs) -> dict:
|
||
name = kwargs.get("name", "").strip()
|
||
source = kwargs.get("source", "").strip()
|
||
|
||
if not name:
|
||
return {
|
||
"output": "错误: 必须提供 name 参数",
|
||
"exit_code": 1,
|
||
"is_error": True,
|
||
}
|
||
|
||
# Validate name — it's used to construct filesystem paths in
|
||
# _try_register_skill, so reject anything that could escape the
|
||
# skills directory (e.g. ``../../etc/passwd``).
|
||
if not _SAFE_NAME_RE.match(name):
|
||
return {
|
||
"output": f"错误: 技能名称包含非法字符: {name}(仅允许字母、数字、下划线、连字符)",
|
||
"exit_code": 1,
|
||
"is_error": True,
|
||
}
|
||
|
||
# Build the install command
|
||
if source:
|
||
install_target = source
|
||
else:
|
||
install_target = name
|
||
|
||
# Request confirmation before installing
|
||
if self._confirm_callback:
|
||
confirmed = await self._confirm_callback(f"npx skills install {install_target}")
|
||
if not confirmed:
|
||
return {
|
||
"output": f"技能安装被用户拒绝: {install_target}",
|
||
"exit_code": 126,
|
||
"is_error": True,
|
||
}
|
||
|
||
try:
|
||
# ``--yes`` is an npx flag (NOT a skills flag) that auto-accepts
|
||
# the "Need to install the following packages: skills@x.y.z.
|
||
# Ok to proceed?" prompt. Without it, npx blocks waiting for
|
||
# stdin in the non-interactive subprocess and the install fails
|
||
# with "npm error canceled". The trailing ``-y`` is the skills
|
||
# CLI's own confirm flag.
|
||
proc = await asyncio.create_subprocess_exec(
|
||
"npx",
|
||
"--yes",
|
||
"skills@latest",
|
||
"install",
|
||
install_target,
|
||
"-y",
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
)
|
||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
|
||
output = stdout.decode("utf-8", errors="replace")
|
||
error_output = stderr.decode("utf-8", errors="replace")
|
||
|
||
if proc.returncode == 0:
|
||
# Try to register the skill into skill_registry
|
||
registration_msg = ""
|
||
if self._skill_registry:
|
||
registration_msg = self._try_register_skill(name)
|
||
|
||
return {
|
||
"output": f"技能 {name} 安装成功\n{output}\n{registration_msg}",
|
||
"exit_code": 0,
|
||
"is_error": False,
|
||
}
|
||
else:
|
||
return {
|
||
"output": f"技能 {name} 安装失败 (exit={proc.returncode})\n{error_output}\n{output}",
|
||
"exit_code": proc.returncode,
|
||
"is_error": True,
|
||
"suggestions": [
|
||
"检查技能名称是否正确",
|
||
"使用 source 参数指定完整路径,如 vercel-labs/skills@find-skills",
|
||
"运行 npx skills search <name> 搜索可用技能",
|
||
],
|
||
}
|
||
except asyncio.TimeoutError:
|
||
return {
|
||
"output": f"技能 {name} 安装超时(120s)",
|
||
"exit_code": -1,
|
||
"is_error": True,
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"output": f"技能 {name} 安装异常: {e}",
|
||
"exit_code": -1,
|
||
"is_error": True,
|
||
}
|
||
|
||
def _try_register_skill(self, name: str) -> str:
|
||
"""Try to find and register the installed skill YAML/SKILL.md into skill_registry.
|
||
|
||
``npx skills add`` installs skills as ``{skills_dir}/{name}/SKILL.md``
|
||
(a directory containing a markdown-frontmatter file), NOT as a
|
||
flat ``{name}.yaml``. This method therefore checks both layouts:
|
||
|
||
1. Flat YAML: ``{skills_dir}/{name}.yaml`` (legacy / hand-authored)
|
||
2. Directory with SKILL.md: ``{skills_dir}/{name}/SKILL.md`` (npx skills)
|
||
3. Directory with any .yaml/.yml: ``{skills_dir}/{name}/*.yaml`` (fallback)
|
||
"""
|
||
try:
|
||
from agentkit.skills.loader import SkillLoader
|
||
|
||
search_dirs = [
|
||
os.path.join(os.getcwd(), ".agents", "skills"),
|
||
os.path.join(os.path.expanduser("~"), ".agents", "skills"),
|
||
os.path.join(os.getcwd(), "configs", "skills"),
|
||
]
|
||
|
||
for search_dir in search_dirs:
|
||
# Layout 1: flat {name}.yaml
|
||
yaml_path = os.path.join(search_dir, f"{name}.yaml")
|
||
if os.path.exists(yaml_path):
|
||
loader = SkillLoader(
|
||
skill_registry=self._skill_registry,
|
||
tool_registry=self._tool_registry,
|
||
)
|
||
loader.load_from_file(yaml_path)
|
||
return f"技能已注册到系统(来源: {yaml_path})"
|
||
|
||
# Layout 2 & 3: directory-based skills ({name}/SKILL.md or {name}/*.yaml)
|
||
for search_dir in search_dirs:
|
||
skill_dir = os.path.join(search_dir, name)
|
||
if not os.path.isdir(skill_dir):
|
||
continue
|
||
# Prefer SKILL.md (the format npx skills actually produces)
|
||
md_path = os.path.join(skill_dir, "SKILL.md")
|
||
if os.path.isfile(md_path):
|
||
loader = SkillLoader(
|
||
skill_registry=self._skill_registry,
|
||
tool_registry=self._tool_registry,
|
||
)
|
||
loader.load_from_skill_md(md_path)
|
||
return f"技能已注册到系统(来源: {md_path})"
|
||
# Fallback: any YAML file inside the directory
|
||
for fname in sorted(os.listdir(skill_dir)):
|
||
if fname.endswith((".yaml", ".yml")):
|
||
yaml_path = os.path.join(skill_dir, fname)
|
||
loader = SkillLoader(
|
||
skill_registry=self._skill_registry,
|
||
tool_registry=self._tool_registry,
|
||
)
|
||
loader.load_from_file(yaml_path)
|
||
return f"技能已注册到系统(来源: {yaml_path})"
|
||
|
||
return "技能文件已下载,但未找到 YAML/SKILL.md 配置文件进行注册。可能需要重启服务。"
|
||
except Exception as e:
|
||
logger.warning(f"Failed to register skill {name}: {e}")
|
||
return f"技能文件已下载,但注册失败: {e}"
|