cli.py 4.1 KB

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