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
@@ -0,0 +1,238 @@
1
+ """OpenAI-compatible endpoint runner: a small tool-using agent loop over Chat Completions.
2
+
3
+ Works with OpenAI and anything that speaks the same API (vLLM, Ollama,
4
+ OpenRouter, LM Studio, ...).
5
+
6
+ APPROXIMATION: this is not a real skill-loading agent. With the skill, SKILL.md
7
+ is pasted into the system prompt and the skill folder is copied to
8
+ `.skills/<name>/` in the sandbox. Real agents only see the skill's description
9
+ and decide whether to load it, so trigger rates are not measured here and
10
+ results are labeled approximate in every report.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+ from skillproof.errors import RunnerNotReady
23
+ from skillproof.runners.base import AgentRun, Runner, RunRequest, ToolCall, Usage
24
+ from skillproof.sandbox import run_shell
25
+ from skillproof.skill import Skill, copy_skill
26
+
27
+ MAX_TOOL_OUTPUT = 20_000 # characters returned to the model per tool call
28
+ COMMAND_TIMEOUT = 120 # seconds per run_command call
29
+
30
+ BASE_PROMPT = """\
31
+ You are a capable assistant working in a project directory. Use the tools to
32
+ inspect files, write files and run shell commands when that helps. When you are
33
+ done, reply with your final answer as plain text.
34
+ """
35
+
36
+ SKILL_PROMPT = """
37
+ A skill is installed. Its files are in `.skills/{name}/`. Follow its
38
+ instructions when they are relevant to the request.
39
+
40
+ <skill name="{name}">
41
+ {content}
42
+ </skill>
43
+ """
44
+
45
+ TOOLS: list[dict[str, Any]] = [
46
+ {
47
+ "type": "function",
48
+ "function": {
49
+ "name": "run_command",
50
+ "description": "Run a shell command in the project directory.",
51
+ "parameters": {
52
+ "type": "object",
53
+ "properties": {"command": {"type": "string"}},
54
+ "required": ["command"],
55
+ },
56
+ },
57
+ },
58
+ {
59
+ "type": "function",
60
+ "function": {
61
+ "name": "read_file",
62
+ "description": "Read a text file (path relative to the project directory).",
63
+ "parameters": {
64
+ "type": "object",
65
+ "properties": {"path": {"type": "string"}},
66
+ "required": ["path"],
67
+ },
68
+ },
69
+ },
70
+ {
71
+ "type": "function",
72
+ "function": {
73
+ "name": "write_file",
74
+ "description": "Create or overwrite a text file (path relative to the project).",
75
+ "parameters": {
76
+ "type": "object",
77
+ "properties": {"path": {"type": "string"}, "content": {"type": "string"}},
78
+ "required": ["path", "content"],
79
+ },
80
+ },
81
+ },
82
+ {
83
+ "type": "function",
84
+ "function": {
85
+ "name": "list_files",
86
+ "description": "List files under a directory (relative to the project).",
87
+ "parameters": {
88
+ "type": "object",
89
+ "properties": {"path": {"type": "string"}},
90
+ },
91
+ },
92
+ },
93
+ ]
94
+
95
+
96
+ def system_prompt(skill: Skill | None) -> str:
97
+ if skill is None:
98
+ return BASE_PROMPT
99
+ return BASE_PROMPT + SKILL_PROMPT.format(name=skill.name, content=skill.skill_md.strip())
100
+
101
+
102
+ def _inside(workdir: Path, rel: str) -> Path:
103
+ path = (workdir / rel).resolve()
104
+ if not path.is_relative_to(workdir.resolve()):
105
+ raise ValueError(f"{rel!r} is outside the project directory")
106
+ return path
107
+
108
+
109
+ def _truncate(text: str) -> str:
110
+ if len(text) <= MAX_TOOL_OUTPUT:
111
+ return text
112
+ return text[:MAX_TOOL_OUTPUT] + f"\n... [truncated {len(text) - MAX_TOOL_OUTPUT} chars]"
113
+
114
+
115
+ async def run_tool(name: str, args: dict[str, Any], workdir: Path) -> str:
116
+ """Execute one tool call inside the sandbox and return its text result."""
117
+ try:
118
+ if name == "run_command":
119
+ code, out = await run_shell(str(args["command"]), workdir, COMMAND_TIMEOUT)
120
+ if code is None:
121
+ return f"command timed out after {COMMAND_TIMEOUT}s"
122
+ return _truncate(f"exit code {code}\n{out}")
123
+ if name == "read_file":
124
+ return _truncate(_inside(workdir, args["path"]).read_text(errors="replace"))
125
+ if name == "write_file":
126
+ target = _inside(workdir, args["path"])
127
+ target.parent.mkdir(parents=True, exist_ok=True)
128
+ target.write_text(str(args["content"]))
129
+ return f"wrote {args['path']}"
130
+ if name == "list_files":
131
+ root = _inside(workdir, args.get("path") or ".")
132
+ files = sorted(str(p.relative_to(workdir)) for p in root.rglob("*") if p.is_file())
133
+ return _truncate("\n".join(files) or "(no files)")
134
+ return f"unknown tool {name!r}"
135
+ except Exception as exc: # tool errors go back to the model, like a real agent
136
+ return f"error: {exc}"
137
+
138
+
139
+ class OpenAICompatibleRunner(Runner):
140
+ name = "openai-compatible"
141
+ vendor = "openai"
142
+ approximate = True
143
+ requires_model = True
144
+
145
+ class Options(BaseModel):
146
+ model_config = ConfigDict(extra="forbid")
147
+
148
+ base_url: str | None = None # None = api.openai.com
149
+ api_key_env: str = "OPENAI_API_KEY"
150
+ tools: bool = True # set false for endpoints without function calling
151
+ max_turns: int = Field(default=20, ge=1)
152
+
153
+ options: Options
154
+
155
+ def check_ready(self) -> None:
156
+ if self.options.base_url is None and not os.environ.get(self.options.api_key_env):
157
+ raise RunnerNotReady(
158
+ f"{self.options.api_key_env} is not set.",
159
+ hint=f"export {self.options.api_key_env}=..., or set runner_options.base_url "
160
+ "to a local endpoint (e.g. http://localhost:11434/v1 for Ollama).",
161
+ )
162
+
163
+ def client(self) -> Any:
164
+ from openai import AsyncOpenAI
165
+
166
+ # Local endpoints often need no key, but the client insists on one.
167
+ api_key = os.environ.get(self.options.api_key_env) or "not-needed"
168
+ return AsyncOpenAI(base_url=self.options.base_url, api_key=api_key)
169
+
170
+ async def run(self, request: RunRequest) -> AgentRun:
171
+ if request.skill is not None:
172
+ copy_skill(
173
+ request.skill,
174
+ request.workdir / ".skills" / request.skill.name,
175
+ exclude=request.skill_exclude,
176
+ )
177
+ client = self.client()
178
+ messages: list[dict[str, Any]] = [
179
+ {"role": "system", "content": system_prompt(request.skill)},
180
+ {"role": "user", "content": request.prompt},
181
+ ]
182
+ usage = Usage()
183
+ calls: list[ToolCall] = []
184
+ warnings: list[str] = []
185
+ final = ""
186
+ turns = 0
187
+ try:
188
+ for turns in range(1, self.options.max_turns + 1): # noqa: B007 - used after loop
189
+ kwargs: dict[str, Any] = {"model": request.model, "messages": messages}
190
+ if self.options.tools:
191
+ kwargs["tools"] = TOOLS
192
+ response = await client.chat.completions.create(**kwargs)
193
+ add_usage(usage, response.usage)
194
+ message = response.choices[0].message
195
+ messages.append(message.model_dump(exclude_none=True))
196
+ if not message.tool_calls:
197
+ final = message.content or ""
198
+ break
199
+ for tool_call in message.tool_calls:
200
+ args = parse_arguments(tool_call.function.arguments)
201
+ output = await run_tool(tool_call.function.name, args, request.workdir)
202
+ calls.append(ToolCall(name=tool_call.function.name, input=args, output=output))
203
+ messages.append(
204
+ {"role": "tool", "tool_call_id": tool_call.id, "content": output}
205
+ )
206
+ else:
207
+ warnings.append(f"stopped early: max_turns ({self.options.max_turns})")
208
+ finally:
209
+ await client.close()
210
+
211
+ return AgentRun(
212
+ final_text=final,
213
+ transcript=messages,
214
+ tool_calls=calls,
215
+ usage=usage,
216
+ num_turns=turns,
217
+ model=request.model,
218
+ triggered=None, # can't be measured: the skill is always in the prompt
219
+ warnings=warnings,
220
+ )
221
+
222
+
223
+ def parse_arguments(raw: str | None) -> dict[str, Any]:
224
+ try:
225
+ args = json.loads(raw or "{}")
226
+ except json.JSONDecodeError:
227
+ return {"_invalid_json": raw}
228
+ return args if isinstance(args, dict) else {"_value": args}
229
+
230
+
231
+ def add_usage(total: Usage, usage: Any) -> None:
232
+ if usage is None:
233
+ return
234
+ details = getattr(usage, "prompt_tokens_details", None)
235
+ cached = (getattr(details, "cached_tokens", None) or 0) if details else 0
236
+ total.input_tokens += (usage.prompt_tokens or 0) - cached
237
+ total.cache_read_tokens += cached
238
+ total.output_tokens += usage.completion_tokens or 0
skillproof/sandbox.py ADDED
@@ -0,0 +1,85 @@
1
+ """Fresh temp directories for each run, so runs never touch the user's project.
2
+
3
+ Sandboxes live in the system temp dir (not inside the user's repo) so agents
4
+ that walk up the directory tree looking for config (CLAUDE.md, AGENTS.md, .git)
5
+ don't pick up the user's real project.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import re
13
+ import shutil
14
+ import signal
15
+ import subprocess
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ # Env vars whose values must never reach logs, saved transcripts, or `command`
20
+ # assertions. Matched against the variable *name*.
21
+ SECRET_NAME = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)", re.IGNORECASE)
22
+ MIN_SECRET_LENGTH = 8 # shorter values are too likely to collide with normal text
23
+
24
+
25
+ def create_sandbox(fixtures: Path | None = None, prefix: str = "skillproof-") -> Path:
26
+ workdir = Path(tempfile.mkdtemp(prefix=prefix)).resolve()
27
+ if fixtures is not None:
28
+ shutil.copytree(fixtures, workdir, dirs_exist_ok=True)
29
+ return workdir
30
+
31
+
32
+ def remove_sandbox(workdir: Path) -> None:
33
+ shutil.rmtree(workdir, ignore_errors=True)
34
+
35
+
36
+ def scrubbed_env() -> dict[str, str]:
37
+ """A copy of os.environ without anything that looks like a credential."""
38
+ return {k: v for k, v in os.environ.items() if not SECRET_NAME.search(k)}
39
+
40
+
41
+ def secret_values() -> list[str]:
42
+ values = [
43
+ v for k, v in os.environ.items() if SECRET_NAME.search(k) and len(v) >= MIN_SECRET_LENGTH
44
+ ]
45
+ return sorted(set(values), key=len, reverse=True)
46
+
47
+
48
+ def redact(text: str) -> str:
49
+ """Replace any credential values from the environment with ***."""
50
+ for value in secret_values():
51
+ text = text.replace(value, "***")
52
+ return text
53
+
54
+
55
+ def _run_shell(command: str, cwd: Path, timeout: float) -> tuple[int | None, str]:
56
+ proc = subprocess.Popen(
57
+ command,
58
+ shell=True,
59
+ cwd=cwd,
60
+ env=scrubbed_env(),
61
+ stdout=subprocess.PIPE,
62
+ stderr=subprocess.STDOUT,
63
+ start_new_session=True, # so a timeout can kill the whole process group
64
+ )
65
+ try:
66
+ out, _ = proc.communicate(timeout=timeout)
67
+ except subprocess.TimeoutExpired:
68
+ if hasattr(os, "killpg"):
69
+ os.killpg(proc.pid, signal.SIGKILL)
70
+ else:
71
+ proc.kill()
72
+ proc.communicate()
73
+ return None, ""
74
+ return proc.returncode, out.decode(errors="replace")
75
+
76
+
77
+ async def run_shell(command: str, cwd: Path, timeout: float) -> tuple[int | None, str]:
78
+ """Run a shell command in the sandbox without credentials in its environment.
79
+
80
+ Returns (exit code, combined output); exit code None means it timed out.
81
+ Runs in a worker thread with plain `subprocess` rather than asyncio
82
+ subprocesses: on Python 3.10/3.11, asyncio's child watcher can collide with
83
+ the Agent SDK's own subprocesses and misreport exit codes as 255.
84
+ """
85
+ return await asyncio.to_thread(_run_shell, command, cwd, timeout)
skillproof/scaffold.py ADDED
@@ -0,0 +1,153 @@
1
+ """`skillproof init`: write a starter evals.yaml and fixtures folder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from skillproof.errors import SkillproofError
9
+ from skillproof.skill import SKILL_FILE, SkillError, load_skill
10
+
11
+ EVALS_TEMPLATE = """\
12
+ # skillproof evals for the "{name}" skill.
13
+ #
14
+ # Every case runs `trials` times WITH the skill installed and `trials` times
15
+ # WITHOUT it (the baseline). skillproof then reports whether the skill was
16
+ # triggered, pass rate with vs. without (the "lift"), and token/latency deltas.
17
+ #
18
+ # Run it: skillproof run {display_path}
19
+
20
+ skill: ./ # folder containing SKILL.md, relative to this file
21
+ runner: {runner}{runner_pad}# claude-code | codex | openai-compatible
22
+ {model_line}
23
+ trials: 5 # runs per case, per variant. More trials = tighter numbers.
24
+ timeout: 300 # seconds per run
25
+
26
+ # Only needed for llm_judge assertions. `auto` picks a judge from a different
27
+ # vendor than the agent under test, so a model never grades itself.
28
+ # judge:
29
+ # provider: auto # auto | anthropic | openai
30
+ # model: ...
31
+
32
+ cases:
33
+ # A task the skill SHOULD help with. Write it the way a real user would:
34
+ # don't name the skill, that's what the trigger rate measures.
35
+ - name: "example task"
36
+ prompt: |
37
+ TODO: replace with a realistic request that needs this skill:
38
+ {description}
39
+ fixtures: ./fixtures/example/ # copied into a fresh temp dir for every run
40
+ assert:
41
+ # Deterministic checks first: cheap, fast and reproducible.
42
+ - type: contains # final answer contains this text (case-insensitive)
43
+ value: "TODO"
44
+ # - type: not_contains
45
+ # value: "I can't"
46
+ # - type: regex
47
+ # pattern: "line \\\\d+"
48
+ # - type: file_exists # path is relative to the sandbox
49
+ # path: "output.md"
50
+ # - type: file_contains
51
+ # path: "output.md"
52
+ # value: "## Summary"
53
+ # - type: command # runs in the sandbox, passes on exit code 0
54
+ # run: "python -m pytest -q"
55
+ # - type: max_tokens # input + output + cache tokens for the run
56
+ # value: 50000
57
+ # - type: llm_judge # optional, graded by a different-vendor model
58
+ # rubric: "States the root cause in the first sentence."
59
+
60
+ # A request the skill should NOT activate for. Catches over-eager
61
+ # descriptions that load the skill (and burn tokens) when it isn't needed.
62
+ - name: "unrelated request"
63
+ prompt: "What is the capital of France? Answer in one word."
64
+ should_trigger: false
65
+ assert:
66
+ - type: contains
67
+ value: "Paris"
68
+ """
69
+
70
+ MODEL_LINES = {
71
+ "openai-compatible": "model: gpt-6.1-sol # required: any model your endpoint serves\n"
72
+ "runner_options:\n"
73
+ " # base_url: http://localhost:11434/v1 # e.g. Ollama; default is api.openai.com",
74
+ "default": "# model: ... # optional; defaults to the agent's own default model",
75
+ }
76
+
77
+ FIXTURE_README = """\
78
+ Files in this folder are copied into a fresh temporary directory before every
79
+ run, so the agent can read and edit them without touching your real project.
80
+
81
+ Replace this file with whatever the "example task" case needs: a repo snippet,
82
+ a log file, a CSV, a failing test...
83
+ """
84
+
85
+ SKILL_TEMPLATE = """\
86
+ ---
87
+ name: {name}
88
+ description: TODO - what this skill does and when the agent should use it.
89
+ ---
90
+
91
+ # {name}
92
+
93
+ TODO: instructions for the agent.
94
+ """
95
+
96
+
97
+ @dataclass
98
+ class InitResult:
99
+ created: list[Path] = field(default_factory=list)
100
+ warnings: list[str] = field(default_factory=list)
101
+
102
+
103
+ def init_skill(path: Path, runner: str = "claude-code", force: bool = False) -> InitResult:
104
+ result = InitResult()
105
+ path.mkdir(parents=True, exist_ok=True)
106
+ evals_path = path / "evals.yaml"
107
+ if evals_path.exists() and not force:
108
+ raise SkillproofError(
109
+ f"{evals_path} already exists.",
110
+ hint="Use --force to overwrite it, or edit it by hand.",
111
+ )
112
+
113
+ name = path.resolve().name
114
+ description = "TODO: describe the task."
115
+ if (path / SKILL_FILE).is_file():
116
+ try:
117
+ skill = load_skill(path)
118
+ name, description = skill.name, skill.description
119
+ except SkillError as exc:
120
+ result.warnings.append(f"{exc.message} {exc.hint or ''}".strip())
121
+ else:
122
+ (path / SKILL_FILE).write_text(SKILL_TEMPLATE.format(name=name), encoding="utf-8")
123
+ result.created.append(path / SKILL_FILE)
124
+ result.warnings.append(f"No {SKILL_FILE} found, so a placeholder was created. Fill it in.")
125
+
126
+ evals_path.write_text(
127
+ EVALS_TEMPLATE.format(
128
+ name=name,
129
+ display_path=_display(path),
130
+ runner=runner,
131
+ runner_pad=" " * max(1, 17 - len(runner)),
132
+ model_line=MODEL_LINES.get(runner, MODEL_LINES["default"]),
133
+ description=" ".join(description.split()),
134
+ ),
135
+ encoding="utf-8",
136
+ )
137
+ result.created.append(evals_path)
138
+
139
+ fixtures = path / "fixtures" / "example"
140
+ fixtures.mkdir(parents=True, exist_ok=True)
141
+ readme = fixtures / "README.txt"
142
+ if not readme.exists():
143
+ readme.write_text(FIXTURE_README, encoding="utf-8")
144
+ result.created.append(readme)
145
+ return result
146
+
147
+
148
+ def _display(path: Path) -> str:
149
+ try:
150
+ rel = path.resolve().relative_to(Path.cwd())
151
+ return f"./{rel}" if str(rel) != "." else "."
152
+ except ValueError:
153
+ return str(path)
skillproof/skill.py ADDED
@@ -0,0 +1,97 @@
1
+ """Loading a skill folder (a directory with a SKILL.md) and copying it into sandboxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+ from pydantic import BaseModel
10
+
11
+ from skillproof.errors import SkillError
12
+
13
+ SKILL_FILE = "SKILL.md"
14
+
15
+ # Never copied into the sandbox along with the skill. The agent must not be able
16
+ # to read the eval cases (and their assertions) while it is being evaluated.
17
+ ALWAYS_EXCLUDED = {".git", ".skillproof", "evals.yaml", "evals.yml", "__pycache__", ".venv"}
18
+
19
+
20
+ class Skill(BaseModel):
21
+ path: Path
22
+ name: str
23
+ description: str
24
+ body: str # SKILL.md content without the frontmatter
25
+
26
+ @property
27
+ def skill_md(self) -> str:
28
+ return (self.path / SKILL_FILE).read_text(encoding="utf-8")
29
+
30
+
31
+ def parse_frontmatter(text: str) -> tuple[dict, str]:
32
+ """Split a markdown file into (frontmatter dict, body). Missing frontmatter -> {}."""
33
+ if not text.startswith("---"):
34
+ return {}, text
35
+ lines = text.splitlines(keepends=True)
36
+ for i, line in enumerate(lines[1:], start=1):
37
+ if line.strip() == "---":
38
+ raw = "".join(lines[1:i])
39
+ body = "".join(lines[i + 1 :])
40
+ try:
41
+ data = yaml.safe_load(raw) or {}
42
+ except yaml.YAMLError as exc:
43
+ raise SkillError(
44
+ f"SKILL.md frontmatter is not valid YAML: {exc}",
45
+ hint="Fix the YAML between the two '---' lines at the top of SKILL.md.",
46
+ ) from exc
47
+ if not isinstance(data, dict):
48
+ return {}, body
49
+ return data, body
50
+ return {}, text
51
+
52
+
53
+ def load_skill(path: Path) -> Skill:
54
+ path = path.resolve()
55
+ skill_md = path / SKILL_FILE
56
+ if not path.is_dir():
57
+ raise SkillError(
58
+ f"Skill folder not found: {path}",
59
+ hint="Set `skill:` in evals.yaml to the folder that contains SKILL.md.",
60
+ )
61
+ if not skill_md.is_file():
62
+ raise SkillError(
63
+ f"No {SKILL_FILE} in {path}",
64
+ hint="A skill is a folder with a SKILL.md file. Point `skill:` at that folder.",
65
+ )
66
+ meta, body = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
67
+ name = str(meta.get("name") or "").strip()
68
+ description = str(meta.get("description") or "").strip()
69
+ if not name or not description:
70
+ raise SkillError(
71
+ f"{skill_md} must have `name` and `description` in its frontmatter.",
72
+ hint=(
73
+ "Start SKILL.md with:\n---\nname: my-skill\n"
74
+ "description: What it does and when to use it.\n---"
75
+ ),
76
+ )
77
+ return Skill(path=path, name=name, description=description, body=body)
78
+
79
+
80
+ def copy_skill(skill: Skill, dest: Path, exclude: list[Path] | None = None) -> Path:
81
+ """Copy the skill folder to `dest`, leaving out eval files and fixtures.
82
+
83
+ `exclude` holds absolute paths (e.g. fixture folders that live inside the
84
+ skill folder) that must not be visible to the agent.
85
+ """
86
+ excluded = {p.resolve() for p in (exclude or [])}
87
+
88
+ def ignore(directory: str, names: list[str]) -> set[str]:
89
+ skipped = set()
90
+ for name in names:
91
+ full = (Path(directory) / name).resolve()
92
+ if name in ALWAYS_EXCLUDED or full in excluded:
93
+ skipped.add(name)
94
+ return skipped
95
+
96
+ shutil.copytree(skill.path, dest, ignore=ignore, dirs_exist_ok=True)
97
+ return dest