skillproof 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. skillproof/__init__.py +3 -0
  2. skillproof/__main__.py +3 -0
  3. skillproof/assertions/__init__.py +62 -0
  4. skillproof/assertions/base.py +44 -0
  5. skillproof/assertions/builtin.py +176 -0
  6. skillproof/assertions/llm_judge.py +24 -0
  7. skillproof/cli.py +313 -0
  8. skillproof/config.py +188 -0
  9. skillproof/engine.py +268 -0
  10. skillproof/errors.py +72 -0
  11. skillproof/estimate.py +83 -0
  12. skillproof/judges/__init__.py +43 -0
  13. skillproof/judges/anthropic_judge.py +59 -0
  14. skillproof/judges/base.py +69 -0
  15. skillproof/judges/mock.py +21 -0
  16. skillproof/judges/openai_judge.py +58 -0
  17. skillproof/parsers/__init__.py +6 -0
  18. skillproof/parsers/claude_code.py +144 -0
  19. skillproof/parsers/codex.py +112 -0
  20. skillproof/report/__init__.py +34 -0
  21. skillproof/report/badge.py +60 -0
  22. skillproof/report/format.py +75 -0
  23. skillproof/report/html_report.py +296 -0
  24. skillproof/report/json_report.py +9 -0
  25. skillproof/report/markdown.py +78 -0
  26. skillproof/report/summary.py +216 -0
  27. skillproof/report/terminal.py +89 -0
  28. skillproof/results.py +88 -0
  29. skillproof/runners/__init__.py +33 -0
  30. skillproof/runners/base.py +102 -0
  31. skillproof/runners/claude_code.py +117 -0
  32. skillproof/runners/codex.py +133 -0
  33. skillproof/runners/mock.py +91 -0
  34. skillproof/runners/openai_compat.py +238 -0
  35. skillproof/sandbox.py +85 -0
  36. skillproof/scaffold.py +153 -0
  37. skillproof/skill.py +97 -0
  38. skillproof/stats.py +228 -0
  39. skillproof-0.1.0.dist-info/METADATA +262 -0
  40. skillproof-0.1.0.dist-info/RECORD +43 -0
  41. skillproof-0.1.0.dist-info/WHEEL +4 -0
  42. skillproof-0.1.0.dist-info/entry_points.txt +2 -0
  43. skillproof-0.1.0.dist-info/licenses/LICENSE +21 -0
skillproof/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """skillproof: pytest for agent skills."""
2
+
3
+ __version__ = "0.1.0"
skillproof/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from skillproof.cli import main
2
+
3
+ main()
@@ -0,0 +1,62 @@
1
+ """Assertion types usable under `assert:` in evals.yaml.
2
+
3
+ To add an assertion type: write a `BaseAssertion` subclass with a unique
4
+ `type: Literal["..."]` field and an async `check()`, then add it to
5
+ `Assertion` below. Validation and error messages come from pydantic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Annotated, get_args
11
+
12
+ from pydantic import Field
13
+
14
+ from skillproof.assertions.base import AssertionResult, BaseAssertion, CheckContext
15
+ from skillproof.assertions.builtin import (
16
+ Command,
17
+ Contains,
18
+ FileContains,
19
+ FileExists,
20
+ MaxTokens,
21
+ NotContains,
22
+ Regex,
23
+ )
24
+ from skillproof.assertions.llm_judge import LLMJudge
25
+ from skillproof.judges.base import JudgeUnavailable
26
+
27
+ Assertion = Annotated[
28
+ Contains | NotContains | Regex | FileExists | FileContains | Command | MaxTokens | LLMJudge,
29
+ Field(discriminator="type"),
30
+ ]
31
+
32
+ # ["contains", "not_contains", ...], read from each class's `type` Literal.
33
+ ASSERTION_TYPES: list[str] = [
34
+ get_args(cls.model_fields["type"].annotation)[0] for cls in get_args(get_args(Assertion)[0])
35
+ ]
36
+
37
+
38
+ async def check_all(assertions: list[BaseAssertion], ctx: CheckContext) -> list[AssertionResult]:
39
+ """Run assertions in order. A crashing assertion counts as a failure, not a crash.
40
+
41
+ JudgeUnavailable is re-raised: the judge broke, not the agent, so the
42
+ engine marks the whole run as errored instead.
43
+ """
44
+ results = []
45
+ for assertion in assertions:
46
+ try:
47
+ results.append(await assertion.check(ctx))
48
+ except JudgeUnavailable:
49
+ raise
50
+ except Exception as exc:
51
+ results.append(assertion.result(False, f"assertion error: {exc}"))
52
+ return results
53
+
54
+
55
+ __all__ = [
56
+ "ASSERTION_TYPES",
57
+ "Assertion",
58
+ "AssertionResult",
59
+ "BaseAssertion",
60
+ "CheckContext",
61
+ "check_all",
62
+ ]
@@ -0,0 +1,44 @@
1
+ """Base class for assertions.
2
+
3
+ An assertion is a pydantic model (so evals.yaml is validated for free) with an
4
+ async `check()` that looks at one finished run and returns an AssertionResult.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from pydantic import BaseModel, ConfigDict
13
+
14
+ from skillproof.judges.base import Judge
15
+ from skillproof.runners.base import AgentRun
16
+
17
+
18
+ @dataclass
19
+ class CheckContext:
20
+ run: AgentRun
21
+ workdir: Path # the sandbox the agent worked in
22
+ judge: Judge | None = None
23
+
24
+
25
+ class AssertionResult(BaseModel):
26
+ type: str
27
+ passed: bool
28
+ message: str
29
+
30
+
31
+ class BaseAssertion(BaseModel):
32
+ model_config = ConfigDict(extra="forbid")
33
+
34
+ type: str
35
+
36
+ def describe(self) -> str:
37
+ """Short human label, e.g. `contains "NullPointerException"`."""
38
+ return self.type
39
+
40
+ async def check(self, ctx: CheckContext) -> AssertionResult:
41
+ raise NotImplementedError
42
+
43
+ def result(self, passed: bool, message: str) -> AssertionResult:
44
+ return AssertionResult(type=self.type, passed=passed, message=message)
@@ -0,0 +1,176 @@
1
+ """Deterministic assertions. These are cheap and reproducible: prefer them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Literal
8
+
9
+ from pydantic import Field, field_validator
10
+
11
+ from skillproof.assertions.base import AssertionResult, BaseAssertion, CheckContext
12
+ from skillproof.sandbox import run_shell
13
+
14
+ MAX_OUTPUT_IN_MESSAGE = 500
15
+
16
+
17
+ def _sandbox_relative(path: str) -> str:
18
+ p = PurePosixPath(path)
19
+ if p.is_absolute() or ".." in p.parts:
20
+ raise ValueError("must be a relative path inside the sandbox (no '/' prefix, no '..')")
21
+ return path
22
+
23
+
24
+ def _snippet(text: str, limit: int = 80) -> str:
25
+ text = " ".join(text.split())
26
+ return text if len(text) <= limit else text[: limit - 1] + "…"
27
+
28
+
29
+ class Contains(BaseAssertion):
30
+ """The agent's final answer contains `value`."""
31
+
32
+ type: Literal["contains"]
33
+ value: str
34
+ case_sensitive: bool = False
35
+
36
+ def describe(self) -> str:
37
+ return f'contains "{self.value}"'
38
+
39
+ async def check(self, ctx: CheckContext) -> AssertionResult:
40
+ haystack, needle = ctx.run.final_text, self.value
41
+ if not self.case_sensitive:
42
+ haystack, needle = haystack.lower(), needle.lower()
43
+ if needle in haystack:
44
+ return self.result(True, f'answer contains "{self.value}"')
45
+ return self.result(False, f'"{self.value}" not in answer: "{_snippet(ctx.run.final_text)}"')
46
+
47
+
48
+ class NotContains(BaseAssertion):
49
+ """The agent's final answer does NOT contain `value`."""
50
+
51
+ type: Literal["not_contains"]
52
+ value: str
53
+ case_sensitive: bool = False
54
+
55
+ def describe(self) -> str:
56
+ return f'not_contains "{self.value}"'
57
+
58
+ async def check(self, ctx: CheckContext) -> AssertionResult:
59
+ haystack, needle = ctx.run.final_text, self.value
60
+ if not self.case_sensitive:
61
+ haystack, needle = haystack.lower(), needle.lower()
62
+ if needle in haystack:
63
+ return self.result(False, f'answer contains forbidden "{self.value}"')
64
+ return self.result(True, f'answer does not contain "{self.value}"')
65
+
66
+
67
+ class Regex(BaseAssertion):
68
+ """The agent's final answer matches a Python regular expression."""
69
+
70
+ type: Literal["regex"]
71
+ pattern: str
72
+
73
+ @field_validator("pattern")
74
+ @classmethod
75
+ def _compiles(cls, pattern: str) -> str:
76
+ try:
77
+ re.compile(pattern)
78
+ except re.error as exc:
79
+ raise ValueError(f"invalid regular expression: {exc}") from exc
80
+ return pattern
81
+
82
+ def describe(self) -> str:
83
+ return f"regex /{self.pattern}/"
84
+
85
+ async def check(self, ctx: CheckContext) -> AssertionResult:
86
+ if re.search(self.pattern, ctx.run.final_text, flags=re.MULTILINE):
87
+ return self.result(True, f"answer matches /{self.pattern}/")
88
+ return self.result(False, f"answer does not match /{self.pattern}/")
89
+
90
+
91
+ class FileExists(BaseAssertion):
92
+ """A file exists in the sandbox after the run."""
93
+
94
+ type: Literal["file_exists"]
95
+ path: str
96
+
97
+ @field_validator("path")
98
+ @classmethod
99
+ def _inside_sandbox(cls, path: str) -> str:
100
+ return _sandbox_relative(path)
101
+
102
+ def describe(self) -> str:
103
+ return f"file_exists {self.path}"
104
+
105
+ async def check(self, ctx: CheckContext) -> AssertionResult:
106
+ if (ctx.workdir / self.path).exists():
107
+ return self.result(True, f"{self.path} exists")
108
+ return self.result(False, f"{self.path} was not created")
109
+
110
+
111
+ class FileContains(BaseAssertion):
112
+ """A file in the sandbox contains `value`."""
113
+
114
+ type: Literal["file_contains"]
115
+ path: str
116
+ value: str
117
+ case_sensitive: bool = False
118
+
119
+ @field_validator("path")
120
+ @classmethod
121
+ def _inside_sandbox(cls, path: str) -> str:
122
+ return _sandbox_relative(path)
123
+
124
+ def describe(self) -> str:
125
+ return f'file_contains {self.path} "{self.value}"'
126
+
127
+ async def check(self, ctx: CheckContext) -> AssertionResult:
128
+ target: Path = ctx.workdir / self.path
129
+ if not target.is_file():
130
+ return self.result(False, f"{self.path} was not created")
131
+ content = target.read_text(encoding="utf-8", errors="replace")
132
+ needle = self.value
133
+ if not self.case_sensitive:
134
+ content, needle = content.lower(), needle.lower()
135
+ if needle in content:
136
+ return self.result(True, f'{self.path} contains "{self.value}"')
137
+ return self.result(False, f'{self.path} does not contain "{self.value}"')
138
+
139
+
140
+ class Command(BaseAssertion):
141
+ """Run a shell command in the sandbox; passes if it exits 0.
142
+
143
+ API keys and tokens are removed from the command's environment.
144
+ """
145
+
146
+ type: Literal["command"]
147
+ run: str
148
+ timeout: float = Field(default=120, gt=0)
149
+
150
+ def describe(self) -> str:
151
+ return f"command `{self.run}`"
152
+
153
+ async def check(self, ctx: CheckContext) -> AssertionResult:
154
+ code, out = await run_shell(self.run, ctx.workdir, self.timeout)
155
+ if code is None:
156
+ return self.result(False, f"`{self.run}` timed out after {self.timeout:g}s")
157
+ if code == 0:
158
+ return self.result(True, f"`{self.run}` exited 0")
159
+ output = out.strip()[-MAX_OUTPUT_IN_MESSAGE:]
160
+ return self.result(False, f"`{self.run}` exited {code}: {output}")
161
+
162
+
163
+ class MaxTokens(BaseAssertion):
164
+ """The run used at most `value` tokens (input + output + cache)."""
165
+
166
+ type: Literal["max_tokens"]
167
+ value: int = Field(gt=0)
168
+
169
+ def describe(self) -> str:
170
+ return f"max_tokens {self.value}"
171
+
172
+ async def check(self, ctx: CheckContext) -> AssertionResult:
173
+ used = ctx.run.usage.total_tokens
174
+ if used <= self.value:
175
+ return self.result(True, f"used {used} tokens (limit {self.value})")
176
+ return self.result(False, f"used {used} tokens, over the limit of {self.value}")
@@ -0,0 +1,24 @@
1
+ """The optional LLM-graded assertion. Non-deterministic: use deterministic ones first."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from skillproof.assertions.base import AssertionResult, BaseAssertion, CheckContext
8
+
9
+
10
+ class LLMJudge(BaseAssertion):
11
+ """A judge model decides whether the final answer satisfies `rubric`."""
12
+
13
+ type: Literal["llm_judge"]
14
+ rubric: str
15
+
16
+ def describe(self) -> str:
17
+ rubric = self.rubric if len(self.rubric) <= 40 else self.rubric[:39] + "…"
18
+ return f'llm_judge "{rubric}"'
19
+
20
+ async def check(self, ctx: CheckContext) -> AssertionResult:
21
+ if ctx.judge is None:
22
+ return self.result(False, "no judge configured")
23
+ verdict = await ctx.judge.grade(self.rubric, ctx.run.final_text)
24
+ return self.result(verdict.passed, f"judge ({ctx.judge.name}): {verdict.reason}")
skillproof/cli.py ADDED
@@ -0,0 +1,313 @@
1
+ """The `skillproof` command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
14
+
15
+ from skillproof import __version__
16
+ from skillproof.config import EvalConfig, load_config
17
+ from skillproof.engine import Engine, RunOptions
18
+ from skillproof.errors import RunAborted, SkillproofError
19
+ from skillproof.estimate import CONFIRM_ABOVE_USD, Estimate, estimate
20
+ from skillproof.report import FORMATS, Summary, build_summary, render_text, terminal, write_reports
21
+ from skillproof.report import badge as badges
22
+ from skillproof.results import SuiteResult, find_latest_run, load_suite
23
+ from skillproof.scaffold import init_skill
24
+
25
+ app = typer.Typer(
26
+ name="skillproof",
27
+ help="pytest for agent skills: prove your skill actually works.",
28
+ no_args_is_help=True,
29
+ add_completion=False,
30
+ pretty_exceptions_enable=False,
31
+ )
32
+ console = Console()
33
+ err_console = Console(stderr=True)
34
+
35
+
36
+ def fail(exc: SkillproofError) -> typer.Exit:
37
+ err_console.print(f"[bold red]error:[/] {exc.message}", highlight=False)
38
+ if exc.hint:
39
+ err_console.print(f"[bold]fix:[/] {exc.hint}", highlight=False)
40
+ return typer.Exit(code=2)
41
+
42
+
43
+ def version_callback(value: bool) -> None:
44
+ if value:
45
+ console.print(f"skillproof {__version__}")
46
+ raise typer.Exit()
47
+
48
+
49
+ @app.callback()
50
+ def root(
51
+ version: Annotated[
52
+ bool,
53
+ typer.Option("--version", callback=version_callback, is_eager=True, help="Show version."),
54
+ ] = False,
55
+ ) -> None:
56
+ pass
57
+
58
+
59
+ @app.command()
60
+ def init(
61
+ path: Annotated[Path, typer.Argument(help="Skill folder (contains SKILL.md).")] = Path("."),
62
+ runner: Annotated[str, typer.Option(help="Agent to test with.")] = "claude-code",
63
+ force: Annotated[
64
+ bool, typer.Option("--force", help="Overwrite an existing evals.yaml.")
65
+ ] = False,
66
+ ) -> None:
67
+ """Scaffold an evals.yaml with example cases."""
68
+ try:
69
+ result = init_skill(path, runner=runner, force=force)
70
+ except SkillproofError as exc:
71
+ raise fail(exc) from exc
72
+ for created in result.created:
73
+ console.print(f"[green]created[/] {created}")
74
+ for warning in result.warnings:
75
+ console.print(f"[yellow]warning:[/] {warning}")
76
+ console.print(f"\nNext: edit {path / 'evals.yaml'}, then run [bold]skillproof run {path}[/]")
77
+
78
+
79
+ @app.command()
80
+ def run(
81
+ path: Annotated[Path, typer.Argument(help="Skill folder or evals.yaml file.")] = Path("."),
82
+ trials: Annotated[
83
+ int | None, typer.Option("--trials", "-n", min=1, help="Override trials per case.")
84
+ ] = None,
85
+ case: Annotated[
86
+ list[str] | None, typer.Option("--case", "-k", help="Only run this case (repeatable).")
87
+ ] = None,
88
+ parallel: Annotated[int, typer.Option("--parallel", "-p", min=1, help="Concurrent runs.")] = 4,
89
+ report_format: Annotated[
90
+ str, typer.Option("--format", "-f", help="terminal, md, html or json.")
91
+ ] = "terminal",
92
+ output: Annotated[
93
+ Path | None, typer.Option("--output", "-o", help="Write the md/html/json report here.")
94
+ ] = None,
95
+ out: Annotated[Path, typer.Option(help="Where transcripts and results are saved.")] = Path(
96
+ ".skillproof"
97
+ ),
98
+ keep_sandboxes: Annotated[
99
+ bool, typer.Option("--keep-sandboxes", help="Save each run's final working dir.")
100
+ ] = False,
101
+ yes: Annotated[
102
+ bool, typer.Option("--yes", "-y", help="Don't ask before an expensive run.")
103
+ ] = False,
104
+ confirm_above: Annotated[
105
+ float, typer.Option(help="Ask for confirmation above this estimated cost (USD).")
106
+ ] = CONFIRM_ABOVE_USD,
107
+ dry_run: Annotated[
108
+ bool, typer.Option("--dry-run", help="Show the plan and cost estimate, run nothing.")
109
+ ] = False,
110
+ min_lift: Annotated[
111
+ float | None,
112
+ typer.Option(help="Exit 1 if the lift is below this many percentage points (CI gate)."),
113
+ ] = None,
114
+ min_trigger_rate: Annotated[
115
+ float | None,
116
+ typer.Option(help="Exit 1 if the trigger rate is below this percentage (CI gate)."),
117
+ ] = None,
118
+ ) -> None:
119
+ """Run the evals with and without the skill, and print a report."""
120
+ check_format(report_format)
121
+ options = RunOptions(
122
+ parallel=parallel,
123
+ out_dir=out,
124
+ keep_sandboxes=keep_sandboxes,
125
+ trials=trials,
126
+ only_cases=case,
127
+ )
128
+ # Machine-readable output goes to stdout, so everything else goes to stderr.
129
+ info = console if report_format == "terminal" else err_console
130
+ try:
131
+ config = load_config(path)
132
+ check_placeholders(config)
133
+ engine = Engine(config, options)
134
+ plan = estimate(engine, out)
135
+ info.print(f"[bold]plan:[/] {plan.describe()}", highlight=False)
136
+ if dry_run:
137
+ return
138
+ confirm_cost(plan, confirm_above, yes)
139
+ with Progress(
140
+ TextColumn("[bold]running[/]"),
141
+ BarColumn(),
142
+ MofNCompleteColumn(),
143
+ TimeElapsedColumn(),
144
+ console=info,
145
+ transient=True,
146
+ ) as progress:
147
+ task = progress.add_task("runs", total=len(engine.jobs()))
148
+ engine.on_trial = lambda _trial: progress.advance(task)
149
+ suite = asyncio.run(engine.run())
150
+ summary = write_reports(suite)
151
+ except KeyboardInterrupt:
152
+ saved = save_partial_reports(out)
153
+ err_console.print(f"\n[yellow]interrupted.[/] Partial results: {saved}", highlight=False)
154
+ raise typer.Exit(code=130) from None
155
+ except RunAborted as exc:
156
+ save_partial_reports(out)
157
+ raise fail(exc) from exc
158
+ except SkillproofError as exc:
159
+ raise fail(exc) from exc
160
+ emit(report_format, summary, suite, output)
161
+ gate(summary, min_lift, min_trigger_rate)
162
+
163
+
164
+ def check_placeholders(config: EvalConfig) -> None:
165
+ """Refuse to pay for running the TODO examples that `skillproof init` writes."""
166
+ for case in config.cases:
167
+ texts = [case.prompt, *(str(a.model_dump()) for a in case.assertions)]
168
+ if any("TODO" in text for text in texts):
169
+ raise SkillproofError(
170
+ f"Case {case.name!r} still has TODO placeholders from `skillproof init`.",
171
+ hint=f"Write a real prompt and assertions in {config.path}, then run again.",
172
+ )
173
+
174
+
175
+ def confirm_cost(plan: Estimate, confirm_above: float, yes: bool) -> None:
176
+ total = plan.total_usd
177
+ if yes or total is None or total <= confirm_above:
178
+ return
179
+ if sys.stdin.isatty() and sys.stdout.isatty():
180
+ if typer.confirm(f"This may cost about ${total:.2f}. Continue?", default=False):
181
+ return
182
+ raise typer.Exit(code=1)
183
+ raise fail(
184
+ SkillproofError(
185
+ f"Estimated cost ~${total:.2f} is above ${confirm_above:.2f}.",
186
+ hint="Pass --yes to run anyway, lower --trials, or raise --confirm-above.",
187
+ )
188
+ )
189
+
190
+
191
+ def save_partial_reports(out: Path) -> str:
192
+ """After an interruption, render reports for whatever finished."""
193
+ try:
194
+ suite = load_suite(find_latest_run(out))
195
+ write_reports(suite)
196
+ return f"{suite.run_dir}/report.html"
197
+ except (SkillproofError, OSError, ValueError):
198
+ return str(out / "runs")
199
+
200
+
201
+ def gate(summary: Summary, min_lift: float | None, min_trigger_rate: float | None) -> None:
202
+ """CI gates: exit 1 when the skill doesn't meet the bar."""
203
+ failures = []
204
+ if min_lift is not None:
205
+ if summary.lift is None:
206
+ failures.append("lift could not be measured (no graded runs)")
207
+ elif summary.lift * 100 < min_lift:
208
+ failures.append(f"lift {summary.lift * 100:+.0f}% is below --min-lift {min_lift:+g}%")
209
+ if min_trigger_rate is not None:
210
+ rate = summary.trigger_rate
211
+ if rate is None:
212
+ failures.append("trigger rate could not be measured with this runner")
213
+ elif rate * 100 < min_trigger_rate:
214
+ failures.append(
215
+ f"trigger rate {rate * 100:.0f}% is below --min-trigger-rate {min_trigger_rate:g}%"
216
+ )
217
+ for failure in failures:
218
+ err_console.print(f"[bold red]gate failed:[/] {failure}", highlight=False)
219
+ if failures:
220
+ raise typer.Exit(code=1)
221
+
222
+
223
+ @app.command()
224
+ def report(
225
+ path: Annotated[
226
+ Path | None,
227
+ typer.Argument(help="A run directory or results.json. Default: the latest run."),
228
+ ] = None,
229
+ report_format: Annotated[
230
+ str, typer.Option("--format", "-f", help="terminal, md, html or json.")
231
+ ] = "terminal",
232
+ output: Annotated[
233
+ Path | None, typer.Option("--output", "-o", help="Write the md/html/json report here.")
234
+ ] = None,
235
+ out: Annotated[Path, typer.Option(help="Where runs are saved.")] = Path(".skillproof"),
236
+ ) -> None:
237
+ """Re-render the report for a saved run, without running anything."""
238
+ check_format(report_format)
239
+ try:
240
+ suite = load_suite(path if path is not None else find_latest_run(out))
241
+ except SkillproofError as exc:
242
+ raise fail(exc) from exc
243
+ summary = write_reports(suite) # refresh the saved reports with this version
244
+ emit(report_format, summary, suite, output)
245
+
246
+
247
+ @app.command()
248
+ def badge(
249
+ path: Annotated[Path, typer.Argument(help="Skill folder (or its evals.yaml).")] = Path("."),
250
+ endpoint: Annotated[
251
+ bool, typer.Option("--json", help="Print shields.io endpoint JSON instead of markdown.")
252
+ ] = False,
253
+ out: Annotated[Path, typer.Option(help="Where runs are saved.")] = Path(".skillproof"),
254
+ ) -> None:
255
+ """Print a shields.io badge for the skill's latest run."""
256
+ try:
257
+ skill_path = load_config(path).skill
258
+ except SkillproofError:
259
+ skill_path = path.resolve()
260
+ try:
261
+ summary = build_summary(load_suite(find_latest_run(out, skill_path)))
262
+ except SkillproofError as exc:
263
+ raise fail(exc) from exc
264
+ print(badges.endpoint_json(summary) if endpoint else badges.markdown(summary))
265
+
266
+
267
+ def check_format(report_format: str) -> None:
268
+ if report_format not in FORMATS:
269
+ raise fail(
270
+ SkillproofError(
271
+ f"Unknown report format {report_format!r}.",
272
+ hint=f"Use one of: {', '.join(FORMATS)}.",
273
+ )
274
+ )
275
+
276
+
277
+ def emit(report_format: str, summary: Summary, suite: SuiteResult, output: Path | None) -> None:
278
+ """Show or write the report in the requested format."""
279
+ if report_format == "terminal":
280
+ terminal.render(summary, console)
281
+ return
282
+ text = render_text(report_format, summary, suite)
283
+ if output is None and report_format == "html":
284
+ output = Path(suite.run_dir) / "report.html"
285
+ if output is None:
286
+ sys.stdout.write(text)
287
+ return
288
+ output.parent.mkdir(parents=True, exist_ok=True)
289
+ output.write_text(text, encoding="utf-8")
290
+ err_console.print(f"wrote {output}", highlight=False)
291
+
292
+
293
+ ISSUES_URL = "https://github.com/akhilgarg07/skillproof/issues"
294
+
295
+
296
+ def main() -> None:
297
+ """Console entry point: user errors get a fix-it message, bugs get a bug report link."""
298
+ try:
299
+ app()
300
+ except Exception as exc:
301
+ if os.environ.get("SKILLPROOF_DEBUG"):
302
+ raise
303
+ err_console.print(
304
+ f"[bold red]skillproof crashed:[/] {type(exc).__name__}: {exc}\n"
305
+ f"This is a bug in skillproof, not in your skill. Re-run with SKILLPROOF_DEBUG=1 for "
306
+ f"the traceback and please report it at {ISSUES_URL}",
307
+ highlight=False,
308
+ )
309
+ sys.exit(70)
310
+
311
+
312
+ if __name__ == "__main__":
313
+ main()