- Nx 22.7 monorepo (pnpm 11.1, TypeScript 5.9, Node 24) - apps/api: NestJS 11 (CJS conforme CODING-RULES.md PGD-DB-004) - apps/web: React 19 + Vite 8 (ESM) - libs/shared/api-interface: Zod contract base - Docker Compose dev: Postgres 18, Valkey 8, MinIO, Mailpit - WDS artifacts: - design-artifacts/A-Product-Brief/ (5 docs canônicos + 16 dialogs) - design-artifacts/B-Trigger-Map/ (hub + 4 personas + feature impact) - Stack canon: STACK.md v2.2 + CODING-RULES.md v2.0 + brand.md - AGENTS.md + README.md como entrada para devs/agentes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
261 lines
7.9 KiB
Python
261 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.9"
|
|
# ///
|
|
"""Shared helpers for the eval runner."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
def parse_skill_md(skill_path: Path) -> tuple[str, str, str]:
|
|
"""Return (name, description, body) from the skill's SKILL.md frontmatter."""
|
|
text = (skill_path / "SKILL.md").read_text(encoding="utf-8")
|
|
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", text, re.DOTALL)
|
|
if not fm_match:
|
|
raise ValueError(f"SKILL.md at {skill_path} is missing frontmatter")
|
|
frontmatter, body = fm_match.group(1), fm_match.group(2)
|
|
|
|
name = None
|
|
description_lines: list[str] = []
|
|
in_description = False
|
|
for line in frontmatter.splitlines():
|
|
if line.startswith("name:"):
|
|
name = line.split(":", 1)[1].strip()
|
|
in_description = False
|
|
elif line.startswith("description:"):
|
|
value = line.split(":", 1)[1].strip()
|
|
if value in ("|", ">"):
|
|
in_description = True
|
|
else:
|
|
description_lines = [value]
|
|
in_description = False
|
|
elif in_description and line.startswith((" ", "\t")):
|
|
description_lines.append(line.strip())
|
|
elif in_description:
|
|
in_description = False
|
|
|
|
if not name:
|
|
raise ValueError(f"SKILL.md at {skill_path} is missing a name")
|
|
return name, " ".join(description_lines).strip(), body
|
|
|
|
|
|
def discover_project_root(skill_path: Path) -> Path:
|
|
"""Walk up from the skill looking for _bmad/ or .git; default to skill's grandparent."""
|
|
for parent in [skill_path, *skill_path.parents]:
|
|
if (parent / "_bmad").is_dir() or (parent / ".git").exists():
|
|
return parent
|
|
return skill_path.parent.parent
|
|
|
|
|
|
def discover_evals(
|
|
skill_path: Path,
|
|
project_root: Path,
|
|
explicit: Path | None,
|
|
) -> dict[str, Path]:
|
|
"""Locate evals.json and triggers.json. Return dict with keys 'evals' and/or 'triggers'."""
|
|
found: dict[str, Path] = {}
|
|
|
|
def check_dir(d: Path) -> None:
|
|
if not d.is_dir():
|
|
return
|
|
for key, fname in (("evals", "evals.json"), ("triggers", "triggers.json")):
|
|
candidate = d / fname
|
|
if candidate.is_file() and key not in found:
|
|
found[key] = candidate
|
|
|
|
if explicit is not None:
|
|
explicit = explicit.resolve()
|
|
if explicit.is_file():
|
|
if explicit.name == "evals.json":
|
|
found["evals"] = explicit
|
|
elif explicit.name == "triggers.json":
|
|
found["triggers"] = explicit
|
|
elif explicit.is_dir():
|
|
check_dir(explicit)
|
|
return found
|
|
|
|
skill_name = skill_path.name
|
|
candidates: list[Path] = [
|
|
skill_path / "evals",
|
|
skill_path.parent.parent / "evals" / skill_name,
|
|
project_root / "evals" / skill_name,
|
|
]
|
|
for d in candidates:
|
|
check_dir(d)
|
|
if found:
|
|
break
|
|
|
|
if not found:
|
|
evals_root = project_root / "evals"
|
|
if evals_root.is_dir():
|
|
for sub in evals_root.rglob(skill_name):
|
|
if sub.is_dir():
|
|
check_dir(sub)
|
|
if found:
|
|
break
|
|
|
|
return found
|
|
|
|
|
|
def utc_now_iso() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def new_run_id(skill_name: str) -> str:
|
|
return f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{skill_name}"
|
|
|
|
|
|
def have_docker() -> bool:
|
|
if shutil.which("docker") is None:
|
|
return False
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", "info"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=5,
|
|
)
|
|
return result.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def docker_image_present(image: str = "bmad-eval-runner:latest") -> bool:
|
|
if not have_docker():
|
|
return False
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", "image", "inspect", image],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=10,
|
|
)
|
|
return result.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def read_macos_keychain_credentials() -> str | None:
|
|
"""Read the Claude Code OAuth credentials JSON from the macOS Keychain.
|
|
|
|
Returns the raw JSON string stored under service "Claude Code-credentials",
|
|
or None if unavailable (non-macOS, entry missing, or access denied).
|
|
|
|
Called in the parent process — which owns the Keychain ACL — so the credential
|
|
can be staged into each isolated workspace's `.claude/.credentials.json` before
|
|
`claude -p` is launched. Without this, an isolated subprocess with HOME pointed
|
|
at an empty dir has no auth and every eval fails with "Not logged in."
|
|
"""
|
|
if sys.platform != "darwin":
|
|
return None
|
|
try:
|
|
result = subprocess.run(
|
|
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
capture_output=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode != 0:
|
|
return None
|
|
val = result.stdout.decode("utf-8", errors="replace").strip()
|
|
return val if val else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def stage_credentials(claude_dir: Path, credentials_json: str | None) -> None:
|
|
"""Write credentials_json to <claude_dir>/.credentials.json. No-op if None."""
|
|
if not credentials_json:
|
|
return
|
|
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
(claude_dir / ".credentials.json").write_text(credentials_json, encoding="utf-8")
|
|
|
|
|
|
def write_json(path: Path, data: object) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def read_json(path: Path) -> object:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def parse_skill_dependencies(skill_path: Path) -> list[str]:
|
|
"""Return skill names declared under 'dependencies:' in SKILL.md frontmatter."""
|
|
try:
|
|
text = (skill_path / "SKILL.md").read_text(encoding="utf-8")
|
|
except (FileNotFoundError, OSError):
|
|
return []
|
|
fm = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
|
|
if not fm:
|
|
return []
|
|
deps: list[str] = []
|
|
in_deps = False
|
|
for line in fm.group(1).splitlines():
|
|
if re.match(r"^dependencies\s*:", line):
|
|
in_deps = True
|
|
elif in_deps:
|
|
m = re.match(r"^\s+-\s+(\S+)", line)
|
|
if m:
|
|
deps.append(m.group(1))
|
|
elif not line.startswith((" ", "\t")):
|
|
break
|
|
return deps
|
|
|
|
|
|
def discover_setup_dirs(evals_file: Path, eval_id: str | None = None) -> list[Path]:
|
|
"""Return ordered list of setup overlay dirs that exist.
|
|
|
|
base: <evals_dir>/setup/
|
|
per-eval: <evals_dir>/<eval_id>/setup/
|
|
|
|
Applied base-first so per-eval overlays win on conflict.
|
|
"""
|
|
evals_dir = evals_file.parent
|
|
dirs: list[Path] = []
|
|
base = evals_dir / "setup"
|
|
if base.is_dir():
|
|
dirs.append(base)
|
|
if eval_id:
|
|
per_eval = evals_dir / eval_id / "setup"
|
|
if per_eval.is_dir():
|
|
dirs.append(per_eval)
|
|
return dirs
|
|
|
|
|
|
def apply_setup_overlay(setup_dirs: list[Path], dest: Path) -> None:
|
|
"""Rsync each setup dir onto dest in order (base first, per-eval last)."""
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
for src in setup_dirs:
|
|
if not src.is_dir():
|
|
continue
|
|
subprocess.run(
|
|
["rsync", "-a", f"{src}/", f"{dest}/"],
|
|
check=False,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"parse_skill_md",
|
|
"discover_project_root",
|
|
"discover_evals",
|
|
"utc_now_iso",
|
|
"new_run_id",
|
|
"have_docker",
|
|
"docker_image_present",
|
|
"read_macos_keychain_credentials",
|
|
"stage_credentials",
|
|
"write_json",
|
|
"read_json",
|
|
"parse_skill_dependencies",
|
|
"discover_setup_dirs",
|
|
"apply_setup_overlay",
|
|
]
|