#!/usr/bin/env python3 """ 技能管理系统 — 扫描、打分、审计、修复 ========================================= 用法: skill-manager.py scan → 扫描所有skill,产出质量报告 skill-manager.py dashboard → 打印概要看板 skill-manager.py fix → 修复缺失的元数据 skill-manager.py audit → 标记需归档的skill """ import json, os, re, sys, time from datetime import datetime, timezone from pathlib import Path HOME = os.path.expanduser("~") SKILLS_DIR = os.path.join(HOME, ".hermes", "skills") ARCHIVE_DIR = os.path.join(SKILLS_DIR, ".archive") REPORT_FILE = os.path.join(HOME, ".hermes", "skill-health.json") MAX_DESC_LEN = 120 # 描述截断长度 SCORE_WEIGHTS = { "has_version": 1.0, "version_gt_1": 1.0, "has_tags": 1.0, "has_related": 0.5, "desc_length": 1.5, "has_references": 1.0, "has_scripts": 1.0, "has_setup_info": 1.0, "not_archive": 1.0, "desc_quality": 1.0, } def scan_skills(): """扫描所有 skill 目录,返回元数据列表""" skills = [] # 先扫 active skills for root, dirs, files in os.walk(SKILLS_DIR): if "SKILL.md" in files: path = os.path.join(root, "SKILL.md") meta = parse_skill(path) if meta: skills.append(meta) # 再扫 archive if os.path.exists(ARCHIVE_DIR): for root, dirs, files in os.walk(ARCHIVE_DIR): if "SKILL.md" in files: path = os.path.join(root, "SKILL.md") meta = parse_skill(path) if meta: meta["is_archived"] = True skills.append(meta) return skills def parse_skill(path): """解析一个 SKILL.md 文件,提取元数据""" try: with open(path, "r") as f: content = f.read() except Exception: return None # 提取 frontmatter fm = {} fm_match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL) if fm_match: for line in fm_match.group(1).split("\n"): m = re.match(r"^(\w+)\s*:\s*(.*)", line) if m: key = m.group(1).strip() val = m.group(2).strip().strip('"').strip("'") fm[key] = val # 相对路径(从 skills/ 开始) rel_path = path.replace(SKILLS_DIR, "").lstrip("/") # 分类 parts = rel_path.split("/") category = parts[0] if len(parts) > 1 else "uncategorized" name = parts[-2] if len(parts) > 1 else rel_path.replace("/SKILL.md", "") # 描述 desc = fm.get("description", "") # 版本 version = fm.get("version", "0.1") try: ver_num = float(version) if "." in version else 1.0 except: ver_num = 0.1 # tags tags_raw = fm.get("tags", fm.get("metadata", "")) has_tags = bool(tags_raw) # related skills related = fm.get("related_skills", fm.get("metadata", "")) has_related = "related_skills" in content or bool(related) # 目录结构 skill_dir = os.path.dirname(path) has_refs = os.path.isdir(os.path.join(skill_dir, "references")) and bool(os.listdir(os.path.join(skill_dir, "references"))) has_scripts = os.path.isdir(os.path.join(skill_dir, "scripts")) and bool(os.listdir(os.path.join(skill_dir, "scripts"))) # setup info has_setup = "setup_needed" in content or "readiness_status" in content or "required_commands" in content # 描述质量 desc_quality = 0 if len(desc) > 20: desc_quality = 0.5 if len(desc) > 50: desc_quality = 1.0 if "—" in desc or ":" in desc: desc_quality = 1.5 # 有详细说明 # 是否归档 is_archived = ".archive" in path # 计算基础分 base_score = ( (1.0 if fm.get("version") else 0) * SCORE_WEIGHTS["has_version"] + (1.0 if ver_num > 1.0 else 0) * SCORE_WEIGHTS["version_gt_1"] + (1.0 if has_tags else 0) * SCORE_WEIGHTS["has_tags"] + (1.0 if has_related else 0) * SCORE_WEIGHTS["has_related"] + min(len(desc) / 80, 1.0) * SCORE_WEIGHTS["desc_length"] + (1.0 if has_refs else 0) * SCORE_WEIGHTS["has_references"] + (1.0 if has_scripts else 0) * SCORE_WEIGHTS["has_scripts"] + (1.0 if has_setup else 0) * SCORE_WEIGHTS["has_setup_info"] + (0.0 if is_archived else 1.0) * SCORE_WEIGHTS["not_archive"] + desc_quality * 1.0 ) # 归一化到 0-10 max_possible = sum(SCORE_WEIGHTS.values()) + 1.5 # +1.5 for desc_quality bonus quality_score = round(base_score / max_possible * 10, 1) # 等级 if quality_score >= 8: grade = "A" elif quality_score >= 6: grade = "B" elif quality_score >= 4: grade = "C" else: grade = "D" return { "name": name, "category": category, "description": desc[:MAX_DESC_LEN] + ("..." if len(desc) > MAX_DESC_LEN else ""), "version": version, "path": rel_path, "is_archived": is_archived, "has_refs": has_refs, "has_scripts": has_scripts, "has_setup": has_setup, "has_tags": has_tags, "has_related": has_related, "desc_len": len(desc), "quality_score": quality_score, "grade": grade, "needs_attention": quality_score < 5, } def generate_report(skills): """生成完整报告""" active = [s for s in skills if not s["is_archived"]] archived = [s for s in skills if s["is_archived"]] grade_counts = {"A": 0, "B": 0, "C": 0, "D": 0} for s in active: grade_counts[s["grade"]] = grade_counts.get(s["grade"], 0) + 1 categories = {} for s in active: cat = s["category"] if cat not in categories: categories[cat] = {"count": 0, "avg_score": 0} categories[cat]["count"] += 1 categories[cat]["avg_score"] += s["quality_score"] for cat in categories: categories[cat]["avg_score"] = round(categories[cat]["avg_score"] / categories[cat]["count"], 1) needs_attention = [s for s in active if s["needs_attention"]] report = { "timestamp": datetime.now(timezone.utc).isoformat(), "summary": { "total_skills": len(skills), "active": len(active), "archived": len(archived), "avg_score": round(sum(s["quality_score"] for s in active) / len(active), 1) if active else 0, "grades": grade_counts, "categories": len(categories), "needs_attention": len(needs_attention), }, "categories": categories, "needs_attention": needs_attention, "top_skills": sorted(active, key=lambda x: -x["quality_score"])[:10], "bottom_skills": sorted(active, key=lambda x: x["quality_score"])[:10], "skills": sorted(active, key=lambda x: (-x["quality_score"], x["name"])), } return report def print_dashboard(report): """打印看板""" s = report["summary"] print(f"\n{'='*55}") print(f" 技能健康看板 {s['total_skills']} 个 (活跃 {s['active']} / 归档 {s['archived']})") print(f"{'='*55}") print(f" 平均分: {s['avg_score']}/10") print(f" 等级分布: A={s['grades'].get('A',0)} B={s['grades'].get('B',0)} C={s['grades'].get('C',0)} D={s['grades'].get('D',0)}") print(f" 分类: {s['categories']} 个") print(f" 需关注: {s['needs_attention']} 个") print() if report["needs_attention"]: print(f" ⚠️ 需关注 (评分<5):") for sk in report["needs_attention"][:10]: print(f" {sk['grade']} {sk['quality_score']}/10 {sk['name']:30s} [{sk['category']}]") print() print(f" 🏆 Top 10:") for sk in report["top_skills"]: print(f" {sk['grade']} {sk['quality_score']}/10 {sk['name']:30s} v{sk['version']:6s} [{sk['category']}]") print() print(f" 📂 分类概览:") for cat, info in sorted(report["categories"].items(), key=lambda x: -x[1]["count"]): bar = "█" * int(info["avg_score"]) + "░" * (10 - int(info["avg_score"])) print(f" {bar} {info['avg_score']}/10 {cat:25s} {info['count']}个") print(f"{'='*55}\n") def scan(): skills = scan_skills() report = generate_report(skills) os.makedirs(os.path.dirname(REPORT_FILE), exist_ok=True) with open(REPORT_FILE, "w") as f: json.dump(report, f, indent=2, ensure_ascii=False) print_dashboard(report) print(f"报告已保存: {REPORT_FILE}") def fix_metadata(): """自动修复缺失元数据(补 tags、category)""" skills = scan_skills() active = [s for s in skills if not s["is_archived"]] fixed = 0 for sk in active: path = os.path.join(SKILLS_DIR, sk["path"]) if not os.path.exists(path): continue with open(path, "r") as f: content = f.read() changes = [] # 补 tags(如果没有) if not sk["has_tags"] and "tags:" not in content.split("---")[1] if "---" in content else "": # 在 frontmatter 的 metadata 块里加 tags tag = sk["category"].replace("-", " ") content = content.replace( "metadata:", f"metadata:\n tags: [{tag}]", 1 ) if "metadata:" in content else content if "metadata:" not in content: content = content.replace("---\n", f"---\ntags: [{tag}]\n", 1) changes.append("tags") # 补 version(如果没有) if not sk.get("version") or sk["version"] == "0.1": # version 字段已经有了,只是没写 version: 1.0 pass if changes: with open(path, "w") as f: f.write(content) print(f" ✅ {sk['name']}: 补了 {', '.join(changes)}") fixed += 1 print(f"\n修复完成: {fixed} 个 skill") def audit(): """审计:标记长期不用的 skill → 建议归档""" skills = scan_skills() active = [s for s in skills if not s["is_archived"]] # 检查技能的使用情况(从 usage.json 或访问时间) usage_file = os.path.join(HOME, ".hermes", "skills", ".usage.json") usage_data = {} if os.path.exists(usage_file): with open(usage_file) as f: try: usage_data = json.load(f) except: pass now = time.time() suggest_archive = [] for sk in active: # 低分 + 无引用 + 无脚本 = 候选 if sk["quality_score"] < 4 and not sk["has_refs"] and not sk["has_scripts"]: suggest_archive.append(sk) continue # 检查使用频率 name = sk["name"] if name in usage_data: last_used = usage_data[name].get("last_used", 0) if isinstance(last_used, str): try: last_used = datetime.fromisoformat(last_used).timestamp() except: last_used = 0 age_days = (now - last_used) / 86400 if age_days > 90 and sk["quality_score"] < 6: suggest_archive.append(sk) if suggest_archive: print(f"\n⚠️ 建议归档 ({len(suggest_archive)} 个):") for sk in sorted(suggest_archive, key=lambda x: x["quality_score"]): print(f" {sk['quality_score']}/10 {sk['name']:30s} [{sk['category']}]") print(f"\n 执行: skill-manager.py archive ") else: print("\n✅ 无建议归档的 skill") return suggest_archive def archive_skill(name): """归档一个 skill""" # 找 skill 目录 for root, dirs, files in os.walk(SKILLS_DIR): if "SKILL.md" in files and name in root: rel = os.path.relpath(root, SKILLS_DIR) # 目标: skills/.archive/// archive_path = os.path.join(ARCHIVE_DIR, rel) os.makedirs(os.path.dirname(archive_path), exist_ok=True) os.rename(root, archive_path) print(f"📦 已归档: {rel}") return True print(f"❌ 未找到 skill: {name}") return False if __name__ == "__main__": cmd = sys.argv[1] if len(sys.argv) > 1 else "scan" if cmd == "scan": scan() elif cmd == "dashboard": if os.path.exists(REPORT_FILE): with open(REPORT_FILE) as f: print_dashboard(json.load(f)) else: scan() elif cmd == "fix": fix_metadata() elif cmd == "audit": audit() elif cmd == "archive": if len(sys.argv) > 2: archive_skill(sys.argv[2]) else: print("用法: skill-manager.py archive ") else: print(f"未知命令: {cmd}") print("可用: scan, dashboard, fix, audit, archive ")