/ agent-scan / tools / skill / skill.py
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      }