Files
blackbird/aibox/subsystems/skill_manager.py
T
qing eadba81c26 refactor(aibox): 抽离工具类与子系统,附单测基线
A 批(tools/basic_tools.py):迁移 7 个无外部依赖工具类(FileToolBase/ReadFileTool/WriteFileTool/ListFilesTool/SimpleWebCrawlerTool/GetCurrentTimeTool/CalculatorTool)

Wave1(subsystems/):迁移 5 个叶子子系统(CommandExecutor/SubprocessCommandExecutor/PromptManager/MemoryDatabase/MemoDatabase/SkillManager),aibox.py 减少 ~1440 行

新增 tests/ 单测 113 个用例全绿,依赖方向维持 common -> subsystems -> tools -> aibox 单向
2026-08-13 15:48:55 +08:00

231 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""
subsystems.skill_manager - 扩展技能管理器
抽离自 aibox.py,收集并管理 ~/.aibox/skills/ 目录下的技能文件。
支持两种技能格式:
1. 可执行脚本/程序:执行 --help,第一行为简介
2. Markdown 文件 (.md):第一行为简介(# 标题或普通文本),其余为技能内容
依赖:common (constants, Logger) + 标准库
不反向依赖 aibox 任何符号。
"""
import os
import subprocess
from typing import Dict, List, Optional, Tuple
from common import constants, Logger
class SkillManager:
"""扩展技能管理器 - 收集并管理 ~/.aibox/skills/ 目录下的技能文件。
支持两种技能格式:
1. 可执行脚本/程序:执行 --help,第一行为简介
2. Markdown 文件 (.md):第一行为简介(# 标题或普通文本),其余为技能内容
"""
def __init__(self, logger: Optional[Logger] = None):
self.logger = logger or Logger("SkillManager")
# name -> {path, file, summary, content, is_markdown}
self.skills: Dict[str, Dict] = {}
self._load_skills()
# ------------------------------------------------------------------
# 内部辅助
# ------------------------------------------------------------------
def _run_help(self, skill_path: str) -> str:
"""执行 skill_path --help 并返回完整输出(stdout 优先,否则 stderr)。"""
try:
result = subprocess.run(
[skill_path, "--help"],
capture_output=True,
text=True,
timeout=10,
env=os.environ.copy()
)
output = result.stdout.strip()
if not output:
output = result.stderr.strip()
return output
except Exception as e:
self.logger.warning(f"运行 {skill_path} --help 失败: {e}")
return ""
def _read_markdown_skill(self, skill_path: str) -> Tuple[str, str]:
"""读取 Markdown 技能文件,返回 (简介, 完整内容)"""
try:
with open(skill_path, 'r', encoding='utf-8') as f:
content = f.read().strip()
if not content:
return "(空文件)", ""
# 提取第一行非空内容作为简介
lines = content.splitlines()
summary = ""
for line in lines:
stripped = line.strip()
if stripped:
# 如果是 Markdown 标题,去掉 # 前缀
if stripped.startswith('#'):
summary = stripped.lstrip('#').strip()
else:
summary = stripped
break
if not summary:
summary = os.path.basename(skill_path).replace('.md', '')
return summary, content
except Exception as e:
self.logger.warning(f"读取 Markdown 技能文件失败 {skill_path}: {e}")
return "(读取失败)", ""
@staticmethod
def _first_nonempty_line(text: str) -> str:
"""返回文本中第一行非空内容。"""
for line in text.splitlines():
stripped = line.strip()
if stripped:
return stripped
return ""
def _is_markdown_file(self, filename: str) -> bool:
"""判断是否为 Markdown 文件"""
return filename.lower().endswith('.md')
def _is_executable(self, filepath: str) -> bool:
"""检查文件是否可执行"""
if os.name == 'nt': # Windows
# Windows 下检查扩展名
executable_extensions = {'.exe', '.bat', '.cmd', '.ps1', '.py', '.sh'}
ext = os.path.splitext(filepath)[1].lower()
return ext in executable_extensions
else: # Unix/Linux/Mac
return os.access(filepath, os.X_OK)
# ------------------------------------------------------------------
# 加载 / 重载
# ------------------------------------------------------------------
def _load_skills(self):
"""扫描技能目录,加载所有技能文件(可执行文件或 Markdown 文件)。"""
skills_dir = constants.SKILLS_DIR
self.skills = {}
if not os.path.exists(skills_dir):
self.logger.info(f"技能目录不存在: {skills_dir}")
return
skill_files = [
f for f in os.listdir(skills_dir)
if os.path.isfile(os.path.join(skills_dir, f))
and not f.startswith('_')
and not f.startswith('.')
]
for skill_file in skill_files:
skill_path = os.path.join(skills_dir, skill_file)
skill_name = os.path.splitext(skill_file)[0]
is_markdown = self._is_markdown_file(skill_file)
if is_markdown:
# Markdown 格式技能:直接读取内容
summary, content = self._read_markdown_skill(skill_path)
if content:
self.skills[skill_name] = {
"path": skill_path,
"file": skill_file,
"summary": summary,
"content": content,
"is_markdown": True,
"is_executable": False
}
self.logger.info(f"已加载 Markdown 技能: {skill_name} — {summary}")
else:
self.logger.warning(f"Markdown 技能文件为空(跳过): {skill_file}")
else:
# 可执行文件格式技能:执行 --help 获取简介
if not self._is_executable(skill_path):
self.logger.warning(f"技能文件不可执行(跳过): {skill_file}")
continue
help_output = self._run_help(skill_path)
summary = self._first_nonempty_line(help_output) or f"扩展技能: {skill_name}"
self.skills[skill_name] = {
"path": skill_path,
"file": skill_file,
"summary": summary,
"content": help_output, # 完整的 --help 输出作为内容
"is_markdown": False,
"is_executable": True
}
self.logger.info(f"已加载可执行技能: {skill_name} — {summary}")
self.logger.info(f"共加载 {len(self.skills)} 个技能({len([s for s in self.skills.values() if s['is_markdown']])} 个 Markdown 技能)")
def reload(self) -> int:
"""重新扫描技能目录,返回加载的技能数量。"""
self._load_skills()
return len(self.skills)
# ------------------------------------------------------------------
# 查询
# ------------------------------------------------------------------
def search(self, keyword: str) -> List[Dict]:
"""在技能名称、简介和内容中搜索关键字,返回匹配项列表。"""
kw = keyword.lower()
results = []
for name, info in self.skills.items():
match = (
kw in name.lower() or
kw in info["summary"].lower() or
kw in info.get("content", "").lower()
)
if match:
results.append({
"name": name,
"path": info["path"],
"summary": info["summary"],
"content": info.get("content", ""),
"is_markdown": info.get("is_markdown", False),
"is_executable": info.get("is_executable", False)
})
return results
def get_skill_content(self, skill_name: str) -> Optional[str]:
"""获取技能的内容(Markdown 技能返回完整内容,可执行技能返回 --help 输出)"""
info = self.skills.get(skill_name)
if info:
return info.get("content")
return None
def get_skill(self, skill_name: str) -> Optional[Dict]:
"""获取单个技能的完整信息"""
return self.skills.get(skill_name)
def get_all(self) -> List[Dict]:
"""返回所有技能的列表(含名称、路径、简介和内容)。"""
return [
{
"name": name,
"path": info["path"],
"summary": info["summary"],
"content": info.get("content", ""),
"is_markdown": info.get("is_markdown", False),
"is_executable": info.get("is_executable", False)
}
for name, info in self.skills.items()
]
def count(self) -> int:
return len(self.skills)