"""自研 skill 发现与解析。""" from __future__ import annotations import re from pathlib import Path from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home from skiff.catalog import ( catalog_skill_path, discover_catalog_skills, load_catalog, ) from skiff.sources import ( discover_source_skills, list_source_skills, load_sources, ) def list_builtin_skills() -> list[str]: ensure_skills_home() if not SKILLS_DIR.is_dir(): return [] names: list[str] = [] for entry in sorted(SKILLS_DIR.iterdir()): if not entry.is_dir(): continue if entry.name.startswith("_"): continue if (entry / "SKILL.md").is_file(): names.append(entry.name) return names def builtin_skill_path(name: str) -> Path: path = SKILLS_DIR / name if not (path / "SKILL.md").is_file(): raise SystemExit(f"builtin skill 不存在: {name}") return path def normalize_source(source: str | None) -> str | None: if source == "owned": return "builtin" if source == "registry": return "catalog" if source and source.startswith("registry:"): return f"catalog:{source.split(':', 1)[1]}" return source def split_skill_spec(spec: str, source: str | None = None) -> tuple[str, str | None]: if "/" not in spec: return spec, normalize_source(source) qualified_source, name = spec.split("/", 1) if not qualified_source or not name or "/" in name: raise SystemExit(f"skill 限定名称无效: {spec!r}(应为 source/name)") qualified_source = normalize_source(qualified_source) source = normalize_source(source) if source and source != qualified_source: raise SystemExit( f"skill 来源冲突: {spec!r} 与 --source {source!r} 不一致" ) return name, qualified_source def list_custom_skills(source: str | None = None) -> dict[str, list[str]]: sources = load_sources() if source: if source not in sources: raise SystemExit(f"未配置 source: {source}") return {source: list_source_skills(source, sources[source])} return {name: list_source_skills(name, entry) for name, entry in sources.items()} def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]: """返回 (skill_path, source),未指定来源时拒绝同名歧义。""" ensure_skills_home() name, source = split_skill_spec(name, source) builtin = SKILLS_DIR / name if source == "builtin": if not (builtin / "SKILL.md").is_file(): raise SystemExit(f"builtin source 中找不到 skill: {name}") return builtin, "builtin" catalog = load_catalog() sources = load_sources() if source and source.startswith("catalog:"): provider = source.split(":", 1)[1] if provider not in catalog: raise SystemExit(f"catalog 中找不到 source: {provider}") skills = discover_catalog_skills(provider, catalog[provider]) if name not in skills: available = ", ".join(skills) or "(无)" raise SystemExit( f"catalog source {provider!r} 中找不到 skill {name!r}。可用: {available}" ) return skills[name], f"catalog:{provider}" if source == "catalog": if name not in catalog: raise SystemExit(f"catalog 中找不到 skill source: {name}") path = catalog_skill_path(name, catalog[name]) if not (path / "SKILL.md").is_file(): raise SystemExit( f"catalog skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。" f"请运行: skiff fetch {name}" ) return path, f"catalog:{name}" if source: if source not in sources: raise SystemExit( f"项目依赖 source {source!r},但本机尚未配置。" f"请运行: skiff source add {source} " ) skills = discover_source_skills(source, sources[source]) if name not in skills: raise SystemExit(f"source {source!r} 中找不到 skill: {name}") return skills[name], source candidates: list[tuple[Path, str]] = [] if (builtin / "SKILL.md").is_file(): candidates.append((builtin, "builtin")) if name in catalog: path = catalog_skill_path(name, catalog[name]) if (path / "SKILL.md").is_file() or not path.exists(): candidates.append((path, f"catalog:{name}")) for provider, entry in catalog.items(): if provider == name: continue skills = discover_catalog_skills(provider, entry) if name in skills: candidates.append((skills[name], f"catalog:{provider}")) for source_name, entry in sources.items(): skills = discover_source_skills(source_name, entry) if name in skills: candidates.append((skills[name], source_name)) unique_candidates: list[tuple[Path, str]] = [] seen_paths: set[Path] = set() for path, candidate_source in candidates: resolved = path.resolve() if resolved in seen_paths: continue seen_paths.add(resolved) unique_candidates.append((path, candidate_source)) candidates = unique_candidates if len(candidates) > 1: choices = ", ".join(f"{candidate_source}/{name}" for _, candidate_source in candidates) raise SystemExit(f"skill 名称存在多个来源,请明确指定: {choices}") if candidates: path, resolved_source = candidates[0] if resolved_source.startswith("catalog:") and not path.exists(): provider = resolved_source.split(":", 1)[1] raise SystemExit(f"catalog source {provider!r} 尚未 fetch。请先运行: skiff fetch {provider}") return path, resolved_source raise SystemExit(f"找不到 skill: {name}") def read_skill_meta(skill_dir: Path) -> dict[str, str]: skill_md = skill_dir / "SKILL.md" if not skill_md.is_file(): return {} text = skill_md.read_text(encoding="utf-8") if not text.startswith("---"): return {} end = text.find("\n---", 3) if end == -1: return {} frontmatter = text[3:end] meta: dict[str, str] = {} name_match = re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE) if name_match: meta["name"] = name_match.group(1).strip().strip("\"'") desc_match = re.search( r"^description:\s*(?:>-|>\||>|-)?\s*\n((?:[ \t].+\n?)+)", frontmatter, re.MULTILINE, ) if desc_match: lines = [line.strip() for line in desc_match.group(1).splitlines() if line.strip()] meta["description"] = " ".join(lines) else: inline = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE) if inline: meta["description"] = inline.group(1).strip().strip("\"'") return meta def skill_description(name: str) -> str | None: meta = read_skill_meta(SKILLS_DIR / name) desc = meta.get("description") return desc.strip() if desc else None def validate_skill_name(name: str) -> None: import re if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name): raise SystemExit( f"skill 名称无效: {name!r}(小写 + 连字符,如 security-review)" ) if name == "_template": raise SystemExit("不能使用保留名 _template") _PLACEHOLDER_PATTERNS = ( (r"^name:\s*skill-name\s*$", "skill-name"), (r"^#\s+Skill 名称\s*$", "Skill 名称"), (r"简要描述 skill 做什么、何时触发", "简要描述 skill 做什么"), (r"^-\s*触发场景 1\s*$", "触发场景 1"), (r"^\d+\.\s*第一步\s*$", "第一步"), ) def validate_skill_dir(skill_dir: Path, expected_name: str) -> list[str]: """返回 skill 目录中的校验问题;空列表表示通过。""" issues: list[str] = [] skill_md = skill_dir / "SKILL.md" readme = skill_dir / "README.md" if not skill_md.is_file(): return ["缺少 SKILL.md"] if not readme.is_file(): issues.append("缺少 README.md(面向人类的使用说明)") else: readme_text = readme.read_text(encoding="utf-8") if not readme_text.strip(): issues.append("README.md 为空") if re.search(r"^#\s+skill-name\s*$", readme_text, re.MULTILINE): issues.append("README.md 存在模板占位内容: skill-name") for placeholder in ( "用一句话告诉使用者这个 skill 能解决什么问题", "给出用户可以直接说出的典型请求", "给出一条可以直接交给 Agent 的示例请求", ): if placeholder in readme_text: issues.append(f"README.md 存在模板占位内容: {placeholder}") text = skill_md.read_text(encoding="utf-8") if not text.strip(): return ["SKILL.md 为空"] if not text.startswith("---\n"): return ["SKILL.md 缺少 YAML frontmatter"] end = text.find("\n---", 4) if end == -1: return ["SKILL.md frontmatter 未闭合"] frontmatter = text[4:end] keys = re.findall(r"^([A-Za-z0-9_-]+):", frontmatter, re.MULTILINE) unexpected = sorted(set(keys) - {"name", "description"}) missing = sorted({"name", "description"} - set(keys)) if missing: issues.append(f"frontmatter 缺少字段: {', '.join(missing)}") if unexpected: issues.append(f"frontmatter 只允许 name、description,发现: {', '.join(unexpected)}") meta = read_skill_meta(skill_dir) if meta.get("name") != expected_name: issues.append( f"目录名与 frontmatter name 不一致: {expected_name} != {meta.get('name', '(缺失)')}" ) description = meta.get("description", "").strip() if not description: issues.append("description 不能为空") found_placeholders = [ label for pattern, label in _PLACEHOLDER_PATTERNS if re.search(pattern, text, re.MULTILINE | re.IGNORECASE) ] if found_placeholders: issues.append(f"存在模板占位内容: {', '.join(found_placeholders)}") link_pattern = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") for target in link_pattern.findall(text): target = target.strip().split("#", 1)[0] if not target or "://" in target or target.startswith(("mailto:", "/")): continue if not (skill_dir / target).resolve().is_file(): issues.append(f"引用文件不存在: {target}") for draft_file in ("brief.yaml",): if (skill_dir / draft_file).exists(): issues.append(f"正式 skill 不应包含草稿文件: {draft_file}") return issues