cli.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. """Drill CLI: run, compare, list."""
  2. from __future__ import annotations
  3. import os
  4. import secrets
  5. from pathlib import Path
  6. import click
  7. from dotenv import load_dotenv
  8. PROJECT_ROOT: Path = Path(__file__).parent.parent
  9. load_dotenv(PROJECT_ROOT / ".env")
  10. def _set_superpowers_root_default() -> None:
  11. """Default SUPERPOWERS_ROOT to the parent of evals/ if not already set.
  12. Drill historically required contributors to export SUPERPOWERS_ROOT
  13. pointing at the superpowers checkout. After lifting drill into
  14. superpowers/evals/, the parent of PROJECT_ROOT is always the
  15. superpowers root, so we can supply this default automatically.
  16. Existing SUPERPOWERS_ROOT environment values are respected as overrides.
  17. """
  18. os.environ.setdefault("SUPERPOWERS_ROOT", str(PROJECT_ROOT.parent))
  19. _set_superpowers_root_default()
  20. @click.group()
  21. def main() -> None:
  22. """Drill: Superpowers skill compliance benchmark."""
  23. pass
  24. @main.command()
  25. @click.argument("scenario")
  26. @click.option("--backend", "-b", default=None, help="Backend name (e.g., claude, codex)")
  27. @click.option("--models", "-m", default=None, help="Comma-separated backend names for sweep")
  28. @click.option("--n", "n_runs", type=int, default=1, help="Number of repetitions per backend")
  29. @click.option(
  30. "--backends-dir",
  31. type=click.Path(exists=True, path_type=Path),
  32. default=PROJECT_ROOT / "backends",
  33. )
  34. @click.option(
  35. "--scenarios-dir",
  36. type=click.Path(exists=True, path_type=Path),
  37. default=PROJECT_ROOT / "scenarios",
  38. )
  39. @click.option(
  40. "--fixtures-dir",
  41. type=click.Path(exists=True, path_type=Path),
  42. default=PROJECT_ROOT / "fixtures",
  43. )
  44. @click.option("--results-dir", type=click.Path(path_type=Path), default=PROJECT_ROOT / "results")
  45. def run(
  46. scenario: str,
  47. backend: str | None,
  48. models: str | None,
  49. n_runs: int,
  50. backends_dir: Path,
  51. scenarios_dir: Path,
  52. fixtures_dir: Path,
  53. results_dir: Path,
  54. ) -> None:
  55. """Run a scenario against one or more backends."""
  56. if n_runs < 1:
  57. raise click.ClickException("--n must be at least 1")
  58. if models:
  59. backend_names = [b.strip() for b in models.split(",") if b.strip()]
  60. elif backend:
  61. backend_names = [backend]
  62. else:
  63. raise click.ClickException("Either --backend or --models is required")
  64. scenario_path = scenarios_dir / f"{scenario}.yaml"
  65. if not scenario_path.exists():
  66. raise click.ClickException(f"Scenario not found: {scenario_path}")
  67. sweep_id = secrets.token_hex(4)
  68. from drill.sweep import Sweep
  69. sweep = Sweep(
  70. scenario_path=scenario_path,
  71. backend_names=backend_names,
  72. backends_dir=backends_dir,
  73. fixtures_dir=fixtures_dir,
  74. results_dir=results_dir,
  75. n=n_runs,
  76. sweep_id=sweep_id,
  77. )
  78. total = len(backend_names) * n_runs
  79. click.echo(
  80. f"Running {scenario} | backends: {', '.join(backend_names)} | "
  81. f"n={n_runs} | total runs: {total} | sweep: {sweep_id}"
  82. )
  83. groups = sweep.run_all()
  84. for group in groups:
  85. passed = sum(1 for r in group.runs if r.status == "pass")
  86. failed = sum(1 for r in group.runs if r.status == "fail")
  87. errored = sum(1 for r in group.runs if r.status == "error")
  88. click.echo(f"\n{group.backend}: {passed} passed, {failed} failed, {errored} errors")
  89. if group.partial:
  90. click.echo(" (interrupted — partial results)")
  91. @main.command("list")
  92. @click.option(
  93. "--scenarios-dir",
  94. type=click.Path(exists=True, path_type=Path),
  95. default=PROJECT_ROOT / "scenarios",
  96. )
  97. def list_scenarios(scenarios_dir: Path) -> None:
  98. """List available scenarios."""
  99. import yaml
  100. for f in sorted(scenarios_dir.glob("*.yaml")):
  101. with open(f) as fh:
  102. data = yaml.safe_load(fh)
  103. name = data.get("scenario", f.stem)
  104. desc = data.get("description", "")
  105. click.echo(f" {name:40s} {desc}")
  106. @main.command()
  107. @click.argument("scenario")
  108. @click.option("--sweep", "sweep_id", default=None, help="Filter by sweep ID")
  109. @click.option(
  110. "--results-dir",
  111. type=click.Path(exists=True, path_type=Path),
  112. default=PROJECT_ROOT / "results",
  113. )
  114. def compare(scenario: str, sweep_id: str | None, results_dir: Path) -> None:
  115. """Compare results across backends for a scenario."""
  116. from drill.compare import format_compare_output, load_scenario_results
  117. scenario_dir = results_dir / scenario
  118. if not scenario_dir.exists():
  119. raise click.ClickException(f"No results found for: {scenario}")
  120. results = load_scenario_results(scenario_dir, sweep_id=sweep_id)
  121. if not results:
  122. raise click.ClickException(f"No results found for: {scenario}")
  123. click.echo(format_compare_output(scenario, results))