"""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 ` 安装技能,然后注册到 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 搜索可用技能", ], } 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}"