224 lines
8.4 KiB
Python
224 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
||
"""le-app-design 硬依赖门:解析并活性检查三个上游 Skill。
|
||
|
||
用法:
|
||
python3 check_deps.py [--out <RUN>/deps.json] [--quiet]
|
||
|
||
解析顺序: $LE_APP_DESIGN_DEP_ROOTS(冒号分隔) > ~/.agents/skills > ~/.zcode/skills
|
||
> ~/.codex/skills > ~/.claude/skills
|
||
(优先 ~/.agents/skills:impeccable 的 SKILL.md 自引用该路径。)
|
||
|
||
活性检查按依赖形态各自不同——这就是「真实调用形态」的机器定义:
|
||
ui-ux-pro-max 真跑一次 search.py 检索(stdout 含结果段即活)
|
||
frontend-design 校验 SKILL.md 原文完整可读(纯文档型 Skill,装载即调用)
|
||
impeccable 跑其自包含二进制 --version
|
||
|
||
全部通过 exit 0;任何缺失/失活 exit 1 并输出明确报告。仅 python3 标准库。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
SKILL_KEY = "le-app-design-dep-check"
|
||
|
||
UPSTREAMS = [
|
||
{
|
||
"key": "ui-ux-pro-max",
|
||
"repo": "nextlevelbuilder/ui-ux-pro-max-skill",
|
||
"license": "MIT",
|
||
"install": "git clone https://github.com/nextlevelbuilder/ui-ux-pro-max-skill && 拷贝/链接其 .claude/skills/ui-ux-pro-max 到 ~/.agents/skills/ui-ux-pro-max",
|
||
"kind": "cli-search",
|
||
},
|
||
{
|
||
"key": "frontend-design",
|
||
"repo": "anthropics/skills",
|
||
"license": "Apache-2.0",
|
||
"install": "git clone https://github.com/anthropics/skills && 拷贝/链接 skills/frontend-design 到 ~/.agents/skills/frontend-design",
|
||
"kind": "doc-guidance",
|
||
},
|
||
{
|
||
"key": "impeccable",
|
||
"repo": "pbakaus/impeccable",
|
||
"license": "Apache-2.0",
|
||
"install": "git clone https://github.com/pbakaus/impeccable && 拷贝/链接到 ~/.agents/skills/impeccable(含 scripts/ 自包含二进制)",
|
||
"kind": "cli-engine",
|
||
},
|
||
]
|
||
|
||
FRONTMATTER_MIN_BYTES = 2000 # frontend-design 原文约 10KB;残缺副本在此拦下
|
||
|
||
|
||
def candidate_roots() -> list[Path]:
|
||
roots: list[Path] = []
|
||
env = os.environ.get("LE_APP_DESIGN_DEP_ROOTS", "")
|
||
for part in env.split(os.pathsep):
|
||
if part.strip():
|
||
roots.append(Path(part).expanduser())
|
||
home = Path.home()
|
||
roots += [
|
||
home / ".agents" / "skills",
|
||
home / ".zcode" / "skills",
|
||
home / ".codex" / "skills",
|
||
home / ".claude" / "skills",
|
||
]
|
||
seen: set[str] = set()
|
||
unique: list[Path] = []
|
||
for r in roots:
|
||
key = str(r)
|
||
if key not in seen:
|
||
seen.add(key)
|
||
unique.append(r)
|
||
return unique
|
||
|
||
|
||
def read_frontmatter_name(skill_md: Path) -> str | None:
|
||
try:
|
||
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
||
except OSError:
|
||
return None
|
||
m = re.match(r"^---\s*\n(.*?\n)?---", text, re.DOTALL)
|
||
if not m:
|
||
return None
|
||
block = m.group(1) or ""
|
||
nm = re.search(r"^name:\s*['\"]?([A-Za-z0-9_-]+)", block, re.MULTILINE)
|
||
return nm.group(1) if nm else None
|
||
|
||
|
||
def resolve(key: str) -> tuple[Path | None, str]:
|
||
"""返回 (解析到的目录, 说明)。"""
|
||
for root in candidate_roots():
|
||
dep_dir = root / key
|
||
skill_md = dep_dir / "SKILL.md"
|
||
if skill_md.is_file():
|
||
name = read_frontmatter_name(skill_md)
|
||
if name == key:
|
||
return dep_dir, f"resolved at {dep_dir}"
|
||
return dep_dir, f"SKILL.md frontmatter name={name!r} != {key!r}(目录在但身份不符)"
|
||
return None, "not found in any candidate root"
|
||
|
||
|
||
def _run(cmd: list[str], timeout: int = 90) -> subprocess.CompletedProcess:
|
||
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
||
return subprocess.run(
|
||
cmd, capture_output=True, text=True, timeout=timeout, env=env
|
||
)
|
||
|
||
|
||
def liveness(spec: dict, dep_dir: Path) -> tuple[bool, str]:
|
||
key, kind = spec["key"], spec["kind"]
|
||
try:
|
||
if kind == "cli-search":
|
||
script = dep_dir / "scripts" / "search.py"
|
||
if not script.is_file():
|
||
return False, f"missing {script}"
|
||
with tempfile.TemporaryDirectory() as td:
|
||
proc = subprocess.run(
|
||
[sys.executable, str(script), "mobile app", "--domain", "style", "-n", "1"],
|
||
capture_output=True, text=True, timeout=90, cwd=td,
|
||
env=dict(os.environ, PYTHONDONTWRITEBYTECODE="1"),
|
||
)
|
||
if proc.returncode != 0:
|
||
return False, f"search.py exit={proc.returncode}: {proc.stderr.strip()[:300]}"
|
||
if "Result" not in proc.stdout:
|
||
return False, "search.py exit=0 but no result section in stdout"
|
||
return True, "search.py live query ok"
|
||
|
||
if kind == "doc-guidance":
|
||
skill_md = dep_dir / "SKILL.md"
|
||
try:
|
||
data = skill_md.read_bytes()
|
||
except OSError as e:
|
||
return False, f"unreadable SKILL.md: {e}"
|
||
if len(data) < FRONTMATTER_MIN_BYTES:
|
||
return False, f"SKILL.md only {len(data)} bytes (<{FRONTMATTER_MIN_BYTES}); likely truncated copy"
|
||
text = data.decode("utf-8", errors="replace")
|
||
if "name: frontend-design" not in text or "description:" not in text:
|
||
return False, "SKILL.md frontmatter incomplete"
|
||
return True, f"original SKILL.md intact ({len(data)} bytes, sha256 {hashlib.sha256(data).hexdigest()[:12]}…)"
|
||
|
||
if kind == "cli-engine":
|
||
launcher = dep_dir / "scripts" / "impeccable"
|
||
if not launcher.is_file():
|
||
return False, f"missing launcher {launcher}"
|
||
proc = _run(["sh", str(launcher), "--version"])
|
||
if proc.returncode != 0:
|
||
return False, f"impeccable --version exit={proc.returncode}: {proc.stderr.strip()[:300]}"
|
||
version = (proc.stdout or "").strip() or (proc.stderr or "").strip()
|
||
return True, f"impeccable binary ok ({version[:60]})"
|
||
except subprocess.TimeoutExpired:
|
||
return False, f"{key} liveness check timed out"
|
||
except OSError as e:
|
||
return False, f"liveness check error: {e}"
|
||
return False, "unknown kind"
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="le-app-design 依赖门")
|
||
ap.add_argument("--out", help="JSON 结果写入路径(如 <RUN>/deps.json)")
|
||
ap.add_argument("--quiet", action="store_true", help="只输出摘要一行")
|
||
args = ap.parse_args()
|
||
|
||
result = {
|
||
"skill": SKILL_KEY,
|
||
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||
"roots_searched": [str(r) for r in candidate_roots()],
|
||
"deps": {},
|
||
"ok": False,
|
||
}
|
||
|
||
for spec in UPSTREAMS:
|
||
dep_dir, note = resolve(spec["key"])
|
||
entry = {
|
||
"repo": spec["repo"],
|
||
"license": spec["license"],
|
||
"kind": spec["kind"],
|
||
"path": str(dep_dir) if dep_dir else None,
|
||
"alive": False,
|
||
"check": note if dep_dir is None else "",
|
||
"install": spec["install"],
|
||
}
|
||
if dep_dir is not None:
|
||
alive, check = liveness(spec, dep_dir)
|
||
entry["alive"] = alive
|
||
entry["check"] = check
|
||
result["deps"][spec["key"]] = entry
|
||
|
||
result["ok"] = all(d["alive"] for d in result["deps"].values())
|
||
|
||
if args.out:
|
||
out_path = Path(args.out).expanduser()
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
lines = []
|
||
for key, d in result["deps"].items():
|
||
mark = "✅" if d["alive"] else "❌"
|
||
where = d["path"] or "未找到"
|
||
lines.append(f"{mark} {key:18} {where}")
|
||
if not d["alive"]:
|
||
lines.append(f" 原因: {d['check']}")
|
||
lines.append(f" 安装: {d['install']}")
|
||
verdict = "依赖门通过(三方解析且活性正常)" if result["ok"] else "依赖门未通过——禁止继续设计流程"
|
||
if args.quiet:
|
||
print(f"{'PASS' if result['ok'] else 'FAIL'}: " + ", ".join(
|
||
f"{k}={'ok' if v['alive'] else 'MISSING'}" for k, v in result["deps"].items()))
|
||
else:
|
||
print("\n".join(lines))
|
||
print(verdict)
|
||
if args.out:
|
||
print(f"JSON: {args.out}")
|
||
return 0 if result["ok"] else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|