skill.py
1 # Copyright (c) 2024-2026 Tencent Zhuque Lab. All rights reserved. 2 # 3 # Licensed under the Apache License, Version 2.0 (the "License"); 4 # you may not use this file except in compliance with the License. 5 # You may obtain a copy of the License at 6 # 7 # http://www.apache.org/licenses/LICENSE-2.0 8 # 9 # Unless required by applicable law or agreed to in writing, software 10 # distributed under the License is distributed on an "AS IS" BASIS, 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 # See the License for the specific language governing permissions and 13 # limitations under the License. 14 # 15 # Requirement: Any integration or derivative work must explicitly attribute 16 # Tencent Zhuque Lab (https://github.com/Tencent/AI-Infra-Guard) in its 17 # documentation or user interface, as detailed in the NOTICE file. 18 19 """ 20 Skill 工具 - 技能管理工具集 21 遵循 Claude Code Skill 标准: 22 - 技能存储在 prompt/skills/ 下的子目录中 23 - 每个技能目录包含一个 SKILL.md 文件 24 - SKILL.md 包含 YAML Frontmatter (元数据) 和 Prompt 内容 25 """ 26 import os 27 import yaml 28 from typing import Any, Optional, Dict, List 29 from tools.registry import register_tool 30 from utils.logging import logger 31 from utils.tool_context import ToolContext 32 from utils.config import base_dir 33 import re 34 35 SKILLS_DIR = os.path.join(base_dir, "prompt", "skills") 36 37 38 def parse_skill_file(file_path: str) -> Dict[str, Any]: 39 """解析带有 YAML Frontmatter 的 Skill 文件""" 40 if not os.path.exists(file_path): 41 return {} 42 43 with open(file_path, 'r', encoding='utf-8') as f: 44 content = f.read() 45 46 meta = {} 47 body = content 48 49 # 使用正则匹配 YAML Frontmatter 50 # 匹配规则:以 --- 开头,非贪婪匹配中间内容,以 --- 结尾 51 # re.DOTALL 允许 . 匹配换行符 52 frontmatter_pattern = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL) 53 match = frontmatter_pattern.match(content) 54 55 if match: 56 yaml_content = match.group(1) 57 meta = yaml.safe_load(yaml_content) or {} 58 # 获取 body:从匹配结束位置开始 59 body = content[match.end():].strip() 60 61 # 确保 meta 中有基本信息 62 if 'description' not in meta: 63 # 尝试从 body 前几行提取描述 64 lines = body.split('\n') 65 desc_lines = [] 66 for line in lines[:5]: 67 line = line.strip() 68 if line and not line.startswith('#'): 69 desc_lines.append(line) 70 meta['description'] = ' '.join(desc_lines)[:200] 71 72 return { 73 "meta": meta, 74 "content": body, 75 "raw": content 76 } 77 78 79 def get_all_skills() -> List[Dict[str, Any]]: 80 """扫描目录获取所有技能""" 81 skills = [] 82 83 if not os.path.exists(SKILLS_DIR): 84 return skills 85 86 # 遍历 prompt/skills 下的一级目录 87 for name in os.listdir(SKILLS_DIR): 88 if name.startswith('.'): 89 continue 90 91 skill_dir = os.path.join(SKILLS_DIR, name) 92 if not os.path.isdir(skill_dir): 93 continue 94 95 # 查找 SKILL.md (不区分大小写) 96 skill_file = None 97 for f in os.listdir(skill_dir): 98 if f.upper() == 'SKILL.MD': 99 skill_file = os.path.join(skill_dir, f) 100 break 101 102 if not skill_file: 103 continue 104 105 data = parse_skill_file(skill_file) 106 meta = data.get('meta', {}) 107 108 skills.append({ 109 "name": name, # 目录名作为 ID 110 "title": meta.get('name', name), # YAML 中的 name 作为标题 111 "description": meta.get('description', ''), 112 "path": skill_file, 113 "dir": skill_dir 114 }) 115 116 return skills 117 118 119 @register_tool 120 def search_skill( 121 query: Optional[str] = None, 122 context: ToolContext = None 123 ) -> dict[str, Any]: 124 """ 125 搜索可用技能 126 127 Args: 128 query: 搜索关键词(可选) 129 """ 130 all_skills = get_all_skills() 131 132 if query: 133 q = query.lower() 134 skills = [ 135 s for s in all_skills 136 if q in s['name'].lower() 137 or q in s['title'].lower() 138 or q in s['description'].lower() 139 ] 140 else: 141 skills = all_skills 142 143 if len(skills) == 0 and query: 144 # No match for the query — fall back to the full list so the caller can pick 145 output_lines = [f"No skills matched '{query}'. Available skills:"] 146 skills = all_skills 147 else: 148 output_lines = [f"Found {len(skills)} skill(s):"] 149 150 for s in skills: 151 output_lines.append(f"- {s['name']}: {s['description']}") 152 153 return { 154 "success": True, 155 "count": len(skills), 156 "skills": "\n".join(output_lines) 157 } 158 159 160 @register_tool 161 def load_skill( 162 name: str, 163 context: ToolContext = None 164 ) -> dict[str, Any]: 165 """ 166 加载指定技能 167 168 Args: 169 name: 技能名称(目录名) 170 """ 171 # 查找匹配的技能 172 target_skill = None 173 174 # 直接检查目录是否存在 175 skill_dir = os.path.join(SKILLS_DIR, name) 176 if os.path.isdir(skill_dir): 177 # 查找文件 178 for f in os.listdir(skill_dir): 179 if f.upper() == 'SKILL.MD': 180 target_skill = os.path.join(skill_dir, f) 181 break 182 183 if not target_skill: 184 # 尝试模糊匹配或查找 185 all_skills = get_all_skills() 186 for s in all_skills: 187 if s['name'] == name: 188 target_skill = s['path'] 189 break 190 191 if not target_skill: 192 return { 193 "success": False, 194 "error": f"Skill '{name}' not found." 195 } 196 197 # 解析文件 198 data = parse_skill_file(target_skill) 199 200 return { 201 "success": True, 202 "content": data["raw"], 203 }