agent-skill-eval 0.2.1__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.
skill_eval/runner.py ADDED
@@ -0,0 +1,424 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import statistics
6
+ import subprocess
7
+ import uuid
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from rich.console import Console
13
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
14
+
15
+ from skill_eval.git_state import capture_git_state, github_repo_slug
16
+ from skill_eval.graders import LLMGrader, grade_assertions
17
+ from skill_eval.harnesses import get_harness
18
+ from skill_eval.models import (
19
+ AgentType,
20
+ BenchmarkResult,
21
+ BenchmarkStats,
22
+ CleanupManifest,
23
+ DeltaStats,
24
+ EvalCase,
25
+ EvalSuite,
26
+ RunMeta,
27
+ StatsPair,
28
+ )
29
+ from skill_eval.skills import SkillInstaller
30
+ from skill_eval.workspace import WorkspaceManager
31
+
32
+ console = Console()
33
+
34
+
35
+ class EvalRunner:
36
+ def __init__(
37
+ self,
38
+ skill_path: Path,
39
+ evals_path: Path,
40
+ workspace_base: Path,
41
+ agents: list[AgentType],
42
+ concurrency: int = 1,
43
+ with_baseline: bool = True,
44
+ grader_model: str = "deepseek/deepseek-v4-flash",
45
+ grader_base_url: Optional[str] = None,
46
+ agent_models: Optional[dict[AgentType, str]] = None,
47
+ source_repo: Optional[str] = None,
48
+ harness_base_url: Optional[str] = None,
49
+ agent_timeout: Optional[int] = None,
50
+ agent_max_retries: Optional[int] = None,
51
+ runs: int = 1,
52
+ ):
53
+ self.skill_path = skill_path
54
+ self.evals_path = evals_path
55
+ self.workspace_base = workspace_base
56
+ self.agents = agents
57
+ self.concurrency = concurrency
58
+ self.with_baseline = with_baseline
59
+ self.grader_model = grader_model
60
+ self.grader_base_url = grader_base_url
61
+ self.agent_models = agent_models or {}
62
+ self.source_repo = source_repo
63
+ self.harness_base_url = harness_base_url
64
+ self.agent_timeout = agent_timeout
65
+ self.agent_max_retries = agent_max_retries
66
+ self.runs = max(1, runs)
67
+
68
+ self.run_id = uuid.uuid4().hex[:8]
69
+ self.suite = self._load_suite()
70
+ self.skill_installer = SkillInstaller(skill_path)
71
+ self.workspace_mgr = WorkspaceManager(workspace_base, source_repo=source_repo)
72
+ self.llm_grader = LLMGrader(model=grader_model, base_url=grader_base_url) if grader_model else None
73
+
74
+ def _load_suite(self) -> EvalSuite:
75
+ with open(self.evals_path, encoding="utf-8") as f:
76
+ data = json.load(f)
77
+ return EvalSuite(**data)
78
+
79
+ def run(self, iteration: int = 1) -> Path:
80
+ iteration_dir = self.workspace_base / f"{self.suite.skill_name}-workspace" / f"iteration-{iteration}"
81
+ iteration_dir.mkdir(parents=True, exist_ok=True)
82
+
83
+ self._save_eval_metadata(iteration_dir)
84
+
85
+ tasks = []
86
+ for eval_case in self.suite.evals:
87
+ for agent_type in self.agents:
88
+ for run_index in range(1, self.runs + 1):
89
+ tasks.append((eval_case, agent_type, True, run_index))
90
+ if self.with_baseline:
91
+ tasks.append((eval_case, agent_type, False, run_index))
92
+
93
+ results: dict[str, dict] = {}
94
+ cleanup_entries: list[CleanupManifest] = []
95
+
96
+ with Progress(
97
+ SpinnerColumn(),
98
+ TextColumn("[progress.description]{task.description}"),
99
+ BarColumn(),
100
+ TaskProgressColumn(),
101
+ console=console,
102
+ ) as progress:
103
+ task_id = progress.add_task("Running evals...", total=len(tasks))
104
+
105
+ with ThreadPoolExecutor(max_workers=self.concurrency) as executor:
106
+ futures = {}
107
+ for eval_case, agent_type, with_skill, run_index in tasks:
108
+ future = executor.submit(
109
+ self._run_single,
110
+ eval_case,
111
+ agent_type,
112
+ with_skill,
113
+ iteration_dir,
114
+ iteration,
115
+ run_index,
116
+ )
117
+ futures[future] = (eval_case, agent_type, with_skill, run_index)
118
+
119
+ for future in as_completed(futures):
120
+ eval_case, agent_type, with_skill, run_index = futures[future]
121
+ key = f"{eval_case.id}-{agent_type.value}-{'with' if with_skill else 'without'}-r{run_index}"
122
+ try:
123
+ result = future.result()
124
+ results[key] = result["summary"]
125
+ if result.get("cleanup_entry"):
126
+ cleanup_entries.append(result["cleanup_entry"])
127
+ except Exception as e:
128
+ console.print(f"[red]Error running {key}: {e}[/red]")
129
+ results[key] = {"error": str(e)}
130
+ progress.advance(task_id)
131
+
132
+ benchmark = self._compute_benchmark(results, iteration_dir)
133
+
134
+ if self.with_baseline:
135
+ for agent_type in self.agents:
136
+ if agent_type.value not in benchmark.deltas:
137
+ console.print(
138
+ f"[yellow]Warning: no with/without-skill delta for {agent_type.value} — "
139
+ f"all of its runs in one config errored[/yellow]"
140
+ )
141
+
142
+ with open(iteration_dir / "benchmark.json", "w", encoding="utf-8") as f:
143
+ json.dump(benchmark.model_dump(), f, indent=2)
144
+
145
+ manifest = self._build_cleanup_manifest(cleanup_entries, iteration_dir)
146
+ with open(iteration_dir / "cleanup.json", "w", encoding="utf-8") as f:
147
+ json.dump(manifest.model_dump(exclude_none=True), f, indent=2)
148
+
149
+ console.print(f"\n[green]Results saved to {iteration_dir}[/green]")
150
+ return iteration_dir
151
+
152
+ def _save_eval_metadata(self, iteration_dir: Path) -> None:
153
+ meta = {
154
+ "skill_name": self.suite.skill_name,
155
+ "evals": [eval_case.model_dump() for eval_case in self.suite.evals],
156
+ "source_repo": self.source_repo,
157
+ }
158
+ with open(iteration_dir / "evals_meta.json", "w", encoding="utf-8") as f:
159
+ json.dump(meta, f, indent=2)
160
+
161
+ def _build_cleanup_manifest(self, entries: list[CleanupManifest], iteration_dir: Path) -> CleanupManifest:
162
+ all_branches: list[str] = []
163
+ all_prs: list[int] = []
164
+ for entry in entries:
165
+ for b in entry.remote_branches:
166
+ if b not in all_branches:
167
+ all_branches.append(b)
168
+ for p in entry.pr_numbers:
169
+ if p not in all_prs:
170
+ all_prs.append(p)
171
+
172
+ return CleanupManifest(
173
+ source_repo=self.source_repo,
174
+ source_repo_slug=github_repo_slug(self.source_repo),
175
+ remote_branches=all_branches,
176
+ pr_numbers=all_prs,
177
+ workspaces=[str(p) for p in self.workspace_mgr.workspaces],
178
+ )
179
+
180
+ def _run_single(
181
+ self,
182
+ eval_case: EvalCase,
183
+ agent_type: AgentType,
184
+ with_skill: bool,
185
+ iteration_dir: Path,
186
+ iteration: int = 1,
187
+ run_index: int = 1,
188
+ ) -> dict:
189
+ eval_slug = f"eval-{eval_case.id}"
190
+ config_slug = "with_skill" if with_skill else "without_skill"
191
+ agent_slug = agent_type.value
192
+ config_dir = iteration_dir / eval_slug / agent_slug / config_slug
193
+ if self.runs > 1:
194
+ config_dir = config_dir / f"run-{run_index}"
195
+ output_dir = config_dir / "outputs"
196
+ output_dir.mkdir(parents=True, exist_ok=True)
197
+
198
+ fixture_files = {}
199
+ for file_path in eval_case.files:
200
+ src = self.evals_path.parent / file_path
201
+ if src.exists():
202
+ fixture_files[Path(file_path).name] = src
203
+
204
+ workspace_name = f"{eval_case.id}-{agent_type.value}-{'ws' if with_skill else 'bs'}"
205
+ if self.runs > 1:
206
+ workspace_name += f"-r{run_index}"
207
+ workspace = self.workspace_mgr.create_workspace(workspace_name, fixture_files)
208
+
209
+ if with_skill:
210
+ self.skill_installer.install(workspace, agent_type)
211
+
212
+ if eval_case.stage_files and fixture_files:
213
+ for name in fixture_files:
214
+ subprocess.run(
215
+ ["git", "add", "--", name],
216
+ cwd=workspace,
217
+ capture_output=True,
218
+ check=False,
219
+ )
220
+
221
+ model = self.agent_models.get(agent_type)
222
+ harness = get_harness(
223
+ agent_type,
224
+ workspace,
225
+ model,
226
+ base_url=self.harness_base_url,
227
+ timeout=self.agent_timeout,
228
+ max_retries=self.agent_max_retries,
229
+ )
230
+
231
+ prompt = self._build_prompt(eval_case, with_skill)
232
+
233
+ pre_state = capture_git_state(workspace, source_repo=self.source_repo)
234
+ with open(output_dir / "pre_state.json", "w", encoding="utf-8") as f:
235
+ json.dump(pre_state.model_dump(), f, indent=2)
236
+
237
+ agent_output, timing, stdout, stderr = harness.run(prompt, output_dir)
238
+
239
+ post_state = capture_git_state(workspace, source_repo=self.source_repo)
240
+ with open(output_dir / "post_state.json", "w", encoding="utf-8") as f:
241
+ json.dump(post_state.model_dump(), f, indent=2)
242
+
243
+ with open(output_dir / "output.txt", "w", encoding="utf-8") as f:
244
+ f.write(agent_output)
245
+
246
+ timing_path = output_dir.parent / "timing.json"
247
+ with open(timing_path, "w", encoding="utf-8") as f:
248
+ json.dump(timing.model_dump(), f, indent=2)
249
+
250
+ run_meta = RunMeta(
251
+ eval_id=eval_case.id,
252
+ agent=agent_type.value,
253
+ with_skill=with_skill,
254
+ iteration=iteration,
255
+ skill_name=self.suite.skill_name,
256
+ source_repo=self.source_repo,
257
+ run_id=self.run_id,
258
+ run_index=run_index,
259
+ )
260
+ meta_path = output_dir.parent / "run_meta.json"
261
+ with open(meta_path, "w", encoding="utf-8") as f:
262
+ json.dump(run_meta.model_dump(), f, indent=2)
263
+
264
+ grading = grade_assertions(
265
+ eval_case.assertions,
266
+ agent_output,
267
+ output_dir,
268
+ eval_case.expected_output,
269
+ self.llm_grader,
270
+ workspace=workspace,
271
+ pre_state=pre_state,
272
+ post_state=post_state,
273
+ should_trigger=eval_case.should_trigger,
274
+ )
275
+
276
+ grading_path = output_dir.parent / "grading.json"
277
+ with open(grading_path, "w", encoding="utf-8") as f:
278
+ json.dump(grading.model_dump(), f, indent=2)
279
+
280
+ cleanup_entry = self._build_cleanup_entry(pre_state, post_state)
281
+
282
+ if os.environ.get("SKILL_EVAL_KEEP_WORKSPACE"):
283
+ console.print(f" [dim]Workspace kept: {workspace}[/dim]")
284
+ else:
285
+ self.workspace_mgr.cleanup(workspace)
286
+
287
+ return {
288
+ "summary": {
289
+ "eval_id": eval_case.id,
290
+ "agent": agent_type.value,
291
+ "with_skill": with_skill,
292
+ "run_index": run_index,
293
+ "timing": timing.model_dump(),
294
+ "grading": grading.model_dump(),
295
+ "agent_output": agent_output[:2000],
296
+ },
297
+ "cleanup_entry": cleanup_entry,
298
+ }
299
+
300
+ def _build_prompt(self, eval_case: EvalCase, with_skill: bool) -> str:
301
+ """Build the prompt sent to the agent.
302
+
303
+ With-skill mode means the skill is installed and available, NOT that
304
+ the prompt is rewritten to invoke it. Eval prompts decide for
305
+ themselves whether to mention the skill, except for the explicit
306
+ ``force_skill_invocation`` opt-in (used by tests that need to assert
307
+ the skill is reachable by name).
308
+ """
309
+ if with_skill and eval_case.force_skill_invocation:
310
+ return f"Use the ${self.suite.skill_name} skill. {eval_case.prompt}"
311
+ return eval_case.prompt
312
+
313
+ def _build_cleanup_entry(
314
+ self,
315
+ pre_state,
316
+ post_state,
317
+ ) -> CleanupManifest:
318
+ pre_remote = set(pre_state.remote_branches)
319
+ post_remote = set(post_state.remote_branches)
320
+ new_remote_branches = sorted(post_remote - pre_remote)
321
+
322
+ pre_pr_numbers = {p.get("number") for p in pre_state.open_prs}
323
+ new_pr_numbers = [p.get("number") for p in post_state.open_prs if p.get("number") not in pre_pr_numbers]
324
+
325
+ return CleanupManifest(
326
+ source_repo=self.source_repo,
327
+ remote_branches=new_remote_branches,
328
+ pr_numbers=[n for n in new_pr_numbers if n is not None],
329
+ )
330
+
331
+ def _compute_benchmark(self, results: dict[str, dict], iteration_dir: Path) -> BenchmarkResult:
332
+ return compute_benchmark(results, self.agents, self.with_baseline)
333
+
334
+ def _compute_stats(self, results: list[dict]) -> BenchmarkStats:
335
+ return compute_stats(results)
336
+
337
+
338
+ def _is_full_pass(result: dict) -> bool:
339
+ summary = result["grading"]["summary"]
340
+ return summary.get("total", 1) > 0 and summary.get("pass_rate", 0.0) >= 1.0
341
+
342
+
343
+ def compute_stats(results: list[dict]) -> BenchmarkStats:
344
+ if not results:
345
+ return BenchmarkStats(
346
+ pass_rate=StatsPair(mean=0.0, stddev=0.0),
347
+ time_seconds=StatsPair(mean=0.0, stddev=0.0),
348
+ tokens=StatsPair(mean=0.0, stddev=0.0),
349
+ )
350
+
351
+ pass_rates = [r["grading"]["summary"]["pass_rate"] for r in results]
352
+ times = [r["timing"]["duration_ms"] / 1000.0 for r in results]
353
+ tokens = [r["timing"]["total_tokens"] for r in results]
354
+
355
+ full_passes = [_is_full_pass(r) for r in results]
356
+
357
+ # pass@k: group runs by eval id; an eval counts as passed if ANY of its
358
+ # runs fully passed. With one run per eval this equals full_pass_rate.
359
+ by_eval: dict[str, list[bool]] = {}
360
+ for r, fp in zip(results, full_passes):
361
+ by_eval.setdefault(str(r.get("eval_id", "?")), []).append(fp)
362
+ k = max(len(v) for v in by_eval.values())
363
+ pass_at_k = sum(1 for v in by_eval.values() if any(v)) / len(by_eval)
364
+
365
+ return BenchmarkStats(
366
+ pass_rate=StatsPair(
367
+ mean=statistics.mean(pass_rates) if pass_rates else 0.0,
368
+ stddev=statistics.stdev(pass_rates) if len(pass_rates) > 1 else 0.0,
369
+ ),
370
+ time_seconds=StatsPair(
371
+ mean=statistics.mean(times) if times else 0.0,
372
+ stddev=statistics.stdev(times) if len(times) > 1 else 0.0,
373
+ ),
374
+ tokens=StatsPair(
375
+ mean=statistics.mean(tokens) if tokens else 0.0,
376
+ stddev=statistics.stdev(tokens) if len(tokens) > 1 else 0.0,
377
+ ),
378
+ full_pass_rate=sum(full_passes) / len(full_passes),
379
+ pass_at_k=pass_at_k,
380
+ k=k,
381
+ )
382
+
383
+
384
+ def compute_benchmark(results: dict[str, dict], agents: list[AgentType], with_baseline: bool) -> BenchmarkResult:
385
+ """Compute the benchmark summary from a results dict.
386
+
387
+ Module-level so it can be called from the ``grade`` command without a
388
+ full ``EvalRunner`` instance. Computes a with/without-skill delta for
389
+ EVERY agent (``deltas``); ``delta`` is kept for backward compatibility
390
+ and is only set when exactly one agent was run.
391
+ """
392
+ run_summary = {}
393
+ deltas: dict[str, DeltaStats] = {}
394
+
395
+ for agent_type in agents:
396
+ with_results = [
397
+ r
398
+ for k, r in results.items()
399
+ if r.get("agent") == agent_type.value and r.get("with_skill") and "error" not in r
400
+ ]
401
+ without_results = [
402
+ r
403
+ for k, r in results.items()
404
+ if r.get("agent") == agent_type.value and not r.get("with_skill") and "error" not in r
405
+ ]
406
+
407
+ with_stats = compute_stats(with_results)
408
+ without_stats = compute_stats(without_results)
409
+
410
+ run_summary[f"{agent_type.value}_with_skill"] = with_stats
411
+ if with_baseline:
412
+ run_summary[f"{agent_type.value}_without_skill"] = without_stats
413
+ if with_results and without_results:
414
+ deltas[agent_type.value] = DeltaStats(
415
+ pass_rate=with_stats.pass_rate.mean - without_stats.pass_rate.mean,
416
+ time_seconds=with_stats.time_seconds.mean - without_stats.time_seconds.mean,
417
+ tokens=with_stats.tokens.mean - without_stats.tokens.mean,
418
+ )
419
+
420
+ delta = None
421
+ if len(agents) == 1 and agents[0].value in deltas:
422
+ delta = deltas[agents[0].value]
423
+
424
+ return BenchmarkResult(run_summary=run_summary, deltas=deltas, delta=delta)
skill_eval/skills.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from skill_eval.models import AgentType
9
+
10
+ SKILL_PATHS = {
11
+ AgentType.OPENCODE: [".opencode/skills"],
12
+ AgentType.CLAUDE_CODE: [".claude/skills"],
13
+ AgentType.CODEX: [".codex/skills"],
14
+ AgentType.FAKE: [".fake/skills"],
15
+ }
16
+
17
+
18
+ class SkillInstaller:
19
+ def __init__(self, skill_path: Path):
20
+ self.skill_path = skill_path
21
+ self.skill_name = skill_path.name
22
+ self.skill_md = skill_path / "SKILL.md"
23
+ if not self.skill_md.exists():
24
+ raise FileNotFoundError(f"SKILL.md not found in {skill_path}")
25
+
26
+ def frontmatter_problems(self) -> list[str]:
27
+ """Return human-readable problems with the SKILL.md frontmatter.
28
+
29
+ Empty list means the frontmatter is usable. Agents rely on ``name``
30
+ and ``description`` to decide when to trigger a skill, so missing
31
+ fields cause silent misbehavior rather than errors.
32
+ """
33
+ text = self.skill_md.read_text()
34
+ if not text.startswith("---"):
35
+ return ["SKILL.md has no YAML frontmatter block (must start with '---')"]
36
+ parts = text.split("---", 2)
37
+ if len(parts) < 3:
38
+ return ["SKILL.md frontmatter is not closed with '---'"]
39
+ try:
40
+ data = yaml.safe_load(parts[1])
41
+ except yaml.YAMLError as e:
42
+ return [f"SKILL.md frontmatter is not valid YAML: {e}"]
43
+ if not isinstance(data, dict):
44
+ return ["SKILL.md frontmatter is not a YAML mapping"]
45
+ problems = []
46
+ for field in ("name", "description"):
47
+ if not data.get(field):
48
+ problems.append(f"SKILL.md frontmatter is missing '{field}'")
49
+ if data.get("name") and data["name"] != self.skill_name:
50
+ problems.append(
51
+ f"SKILL.md frontmatter name '{data['name']}' does not match skill directory name '{self.skill_name}'"
52
+ )
53
+ return problems
54
+
55
+ def install(self, workspace: Path, agent_type: AgentType) -> Path:
56
+ target_dirs = SKILL_PATHS[agent_type]
57
+ installed_to = None
58
+
59
+ for target_dir in target_dirs:
60
+ dest_base = workspace / target_dir / self.skill_name
61
+ dest_base.mkdir(parents=True, exist_ok=True)
62
+
63
+ shutil.copy2(self.skill_md, dest_base / "SKILL.md")
64
+
65
+ for item in self.skill_path.iterdir():
66
+ if item.name == "SKILL.md":
67
+ continue
68
+ if item.is_file():
69
+ shutil.copy2(item, dest_base / item.name)
70
+ elif item.is_dir() and item.name not in ("evals", "__pycache__", ".git"):
71
+ shutil.copytree(item, dest_base / item.name, dirs_exist_ok=True)
72
+
73
+ installed_to = dest_base
74
+
75
+ if installed_to is None:
76
+ raise ValueError(f"No skill paths configured for {agent_type}")
77
+
78
+ return installed_to
79
+
80
+ def uninstall(self, workspace: Path, agent_type: AgentType) -> None:
81
+ for target_dir in SKILL_PATHS[agent_type]:
82
+ dest_base = workspace / target_dir / self.skill_name
83
+ if dest_base.exists():
84
+ shutil.rmtree(dest_base)
85
+
86
+ parent = dest_base.parent
87
+ if parent.exists() and not any(parent.iterdir()):
88
+ parent.rmdir()
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ class WorkspaceManager:
14
+ def __init__(self, base_dir: Path | None = None, source_repo: str | None = None):
15
+ self.base_dir = base_dir or Path(tempfile.gettempdir())
16
+ self.source_repo = source_repo
17
+ self.workspaces: list[Path] = []
18
+
19
+ def create_workspace(self, name: str, fixture_files: dict[str, Path] | None = None) -> Path:
20
+ workspace = self.base_dir / f"skill-eval-{name}"
21
+ if workspace.exists():
22
+ shutil.rmtree(workspace)
23
+
24
+ if self.source_repo:
25
+ self._clone_repo(workspace)
26
+ else:
27
+ workspace.mkdir(parents=True, exist_ok=True)
28
+ self._init_git_repo(workspace)
29
+
30
+ if fixture_files:
31
+ for rel_path, src_path in fixture_files.items():
32
+ dest = workspace / rel_path
33
+ dest.parent.mkdir(parents=True, exist_ok=True)
34
+ if src_path.is_dir():
35
+ shutil.copytree(src_path, dest, dirs_exist_ok=True)
36
+ else:
37
+ shutil.copy2(src_path, dest)
38
+
39
+ self.workspaces.append(workspace)
40
+ return workspace
41
+
42
+ def _clone_repo(self, workspace: Path) -> None:
43
+ subprocess.run(
44
+ ["git", "clone", self.source_repo, str(workspace)],
45
+ capture_output=True,
46
+ check=True,
47
+ )
48
+ subprocess.run(
49
+ ["git", "config", "user.email", "eval@skill-eval.local"],
50
+ cwd=workspace,
51
+ capture_output=True,
52
+ check=True,
53
+ )
54
+ subprocess.run(
55
+ ["git", "config", "user.name", "Skill Eval"],
56
+ cwd=workspace,
57
+ capture_output=True,
58
+ check=True,
59
+ )
60
+
61
+ def _init_git_repo(self, workspace: Path) -> None:
62
+ subprocess.run(
63
+ ["git", "init", "-b", "main"],
64
+ cwd=workspace,
65
+ capture_output=True,
66
+ check=True,
67
+ )
68
+ subprocess.run(
69
+ ["git", "config", "user.email", "eval@skill-eval.local"],
70
+ cwd=workspace,
71
+ capture_output=True,
72
+ check=True,
73
+ )
74
+ subprocess.run(
75
+ ["git", "config", "user.name", "Skill Eval"],
76
+ cwd=workspace,
77
+ capture_output=True,
78
+ check=True,
79
+ )
80
+ subprocess.run(
81
+ ["git", "add", "."],
82
+ cwd=workspace,
83
+ capture_output=True,
84
+ check=True,
85
+ )
86
+ subprocess.run(
87
+ ["git", "commit", "-m", "Initial commit", "--allow-empty"],
88
+ cwd=workspace,
89
+ capture_output=True,
90
+ check=True,
91
+ )
92
+
93
+ def cleanup(self, workspace: Path) -> None:
94
+ if workspace.exists():
95
+ shutil.rmtree(workspace)
96
+ if workspace in self.workspaces:
97
+ self.workspaces.remove(workspace)
98
+
99
+ def cleanup_all(self) -> None:
100
+ for ws in self.workspaces[:]:
101
+ self.cleanup(ws)