diff --git a/src/anthropic/lib/tools/__init__.py b/src/anthropic/lib/tools/__init__.py
index b8593e118..f68fb714b 100644
--- a/src/anthropic/lib/tools/__init__.py
+++ b/src/anthropic/lib/tools/__init__.py
@@ -1,3 +1,4 @@
+from ._skills import normalize_skill_files
from ._beta_runner import BetaToolRunner, BetaAsyncToolRunner, BetaStreamingToolRunner, BetaAsyncStreamingToolRunner
from ._beta_functions import (
ToolError,
@@ -26,4 +27,5 @@
"BetaAbstractMemoryTool",
"BetaAsyncAbstractMemoryTool",
"ToolError",
+ "normalize_skill_files",
]
diff --git a/src/anthropic/lib/tools/_skills.py b/src/anthropic/lib/tools/_skills.py
index f706c8c5c..d0436953f 100644
--- a/src/anthropic/lib/tools/_skills.py
+++ b/src/anthropic/lib/tools/_skills.py
@@ -8,12 +8,13 @@
from __future__ import annotations
import os
+import re
import shutil
import logging
import tarfile
import zipfile
import tempfile
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Sequence
from pathlib import Path, PurePosixPath
from functools import partial
@@ -21,9 +22,151 @@
from anyio.to_thread import run_sync
if TYPE_CHECKING:
+ from ..._types import FileTypes
from ..._client import AsyncAnthropic
-__all__ = ["download_session_skills"]
+__all__ = ["download_session_skills", "normalize_skill_files"]
+
+
+def _read_file_entry_content(content: object) -> bytes | None:
+ """Return the raw bytes of a FileContent value, or None if unreadable."""
+ if isinstance(content, bytes):
+ return content
+ if hasattr(content, "read"):
+ data = content.read()
+ if hasattr(content, "seek"):
+ content.seek(0)
+ return data if isinstance(data, bytes) else None
+ try:
+ return Path(content).read_bytes() # type: ignore[arg-type]
+ except Exception:
+ return None
+
+
+def _parse_skill_name_from_frontmatter(skill_md: bytes) -> str | None:
+ """Extract the ``name:`` field from SKILL.md YAML frontmatter, or ``None``."""
+ text = skill_md.decode("utf-8", errors="replace")
+ fm = re.search(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
+ if fm:
+ name_match = re.search(r"^name:\s*(\S+)", fm.group(1), re.MULTILINE)
+ if name_match:
+ return name_match.group(1).strip()
+ return None
+
+
+def normalize_skill_files(
+ files: Sequence["FileTypes"],
+ *,
+ display_title: str | None = None,
+) -> list["FileTypes"]:
+ """Normalize file paths for :meth:`~anthropic.resources.beta.Skills.create`.
+
+ ``beta.skills.create()`` requires every file path to be prefixed with a
+ top-level directory whose name **exactly matches** the ``name:`` field in
+ the ``SKILL.md`` frontmatter. This function is called automatically inside
+ ``skills.create()`` — you do not need to call it yourself.
+
+ It can also be called explicitly if you want to inspect or log the
+ normalized paths before uploading::
+
+ skill_md = b"---\\nname: my-skill\\n---\\n\\nSkill content."
+ files = normalize_skill_files(
+ [
+ ("SKILL.md", skill_md, "text/markdown"),
+ ("scripts/tool.py", script_bytes, "text/plain"),
+ ]
+ )
+ # [
+ # ("my-skill/SKILL.md", skill_md, "text/markdown"),
+ # ("my-skill/scripts/tool.py", script_bytes, "text/plain"),
+ # ]
+
+ The skill name is resolved in order:
+
+ 1. The ``name:`` field in the ``SKILL.md`` YAML frontmatter.
+ 2. *display_title* normalised to ``lowercase-with-hyphens`` as a fallback
+ (useful when ``SKILL.md`` omits the field).
+
+ Paths that are already under the correct top-level directory are left
+ unchanged (idempotent). A wrong top-level prefix is stripped and replaced
+ rather than prepended, so re-uploading a skill that was created with the
+ wrong prefix is safe.
+
+ Args:
+ files: The sequence of file entries to normalize. Each entry must be a
+ tuple whose first element is the file path string.
+ display_title: Fallback skill name used when the ``SKILL.md``
+ frontmatter does not contain a ``name:`` field. Special characters
+ are replaced with hyphens and the value is lower-cased.
+
+ Returns:
+ A new list with every file path prefixed by the skill name.
+
+ Raises:
+ ValueError: If no ``SKILL.md`` entry is found, or if neither the
+ frontmatter nor *display_title* supplies a skill name.
+ """
+ # Pass 1: locate SKILL.md, parse the skill name, note the current prefix.
+ skill_name: str | None = None
+ skill_md_prefix: str = "" # top-level dir of the SKILL.md entry, or ""
+ found_skill_md = False
+
+ for entry in files:
+ if not isinstance(entry, tuple) or len(entry) < 2:
+ continue
+ filename = entry[0]
+ if not isinstance(filename, str):
+ continue
+ if filename.rsplit("/", 1)[-1] != "SKILL.md":
+ continue
+ found_skill_md = True
+ content = _read_file_entry_content(entry[1])
+ if content is not None:
+ skill_name = _parse_skill_name_from_frontmatter(content)
+ parts = filename.split("/", 1)
+ skill_md_prefix = parts[0] if len(parts) == 2 else ""
+ break
+
+ if not found_skill_md:
+ raise ValueError(
+ "No SKILL.md entry found in the files list. "
+ "Each entry must be a tuple (path, content, ...) where path is a str "
+ "and one path must be 'SKILL.md' or '
/SKILL.md'."
+ )
+
+ # Fallback: derive name from display_title.
+ if skill_name is None and display_title:
+ skill_name = re.sub(r"[^a-z0-9]+", "-", display_title.lower().strip()).strip("-")
+
+ if skill_name is None:
+ raise ValueError(
+ "Could not determine skill name: SKILL.md frontmatter has no 'name:' field "
+ "and no display_title was provided as a fallback.\n"
+ "Add 'name: ' to the SKILL.md frontmatter, or pass "
+ "display_title='' to normalize_skill_files()."
+ )
+
+ # Pass 2: rewrite paths so every entry is under ``{skill_name}/``.
+ #
+ # • Already-correct prefix → unchanged (idempotent).
+ # • Wrong top-level prefix → stripped then replaced (not double-prefixed).
+ # • No prefix → skill name prepended.
+ prefix = f"{skill_name}/"
+ result: list[FileTypes] = []
+ for entry in files:
+ if isinstance(entry, tuple) and entry and isinstance(entry[0], str):
+ path = entry[0]
+ if path.startswith(prefix):
+ result.append(entry)
+ elif skill_md_prefix and path.startswith(f"{skill_md_prefix}/"):
+ relative = path[len(skill_md_prefix) + 1 :]
+ result.append((prefix + relative,) + entry[1:]) # type: ignore[arg-type]
+ else:
+ result.append((prefix + path,) + entry[1:]) # type: ignore[arg-type]
+ else:
+ result.append(entry)
+ return result
+
# Skill dirs hold downloaded, possibly third-party content — keep them
# owner-only rather than inheriting whatever the process umask happens to be.
diff --git a/src/anthropic/resources/beta/skills/skills.py b/src/anthropic/resources/beta/skills/skills.py
index b2241a6da..c397a26ff 100644
--- a/src/anthropic/resources/beta/skills/skills.py
+++ b/src/anthropic/resources/beta/skills/skills.py
@@ -35,6 +35,7 @@
from ....pagination import SyncPageCursor, AsyncPageCursor
from ....types.beta import skill_list_params, skill_create_params
from ...._base_client import AsyncPaginator, make_request_options
+from ....lib.tools._skills import normalize_skill_files
from ....types.anthropic_beta_param import AnthropicBetaParam
from ....types.beta.skill_list_response import SkillListResponse
from ....types.beta.skill_create_response import SkillCreateResponse
@@ -92,8 +93,17 @@ def create(
files: Files to upload for the skill.
- All files must be in the same top-level directory and must include a SKILL.md
- file at the root of that directory.
+ Every path must be prefixed with a top-level directory whose name
+ **exactly matches** the ``name:`` field in the ``SKILL.md``
+ frontmatter (e.g. ``my-skill/SKILL.md``,
+ ``my-skill/scripts/tool.py``). This is handled **automatically**
+ — bare paths like ``"SKILL.md"`` are rewritten before the request
+ is sent, so callers never need to construct the prefix manually::
+
+ # All of these are equivalent — the SDK normalizes them:
+ files=[("SKILL.md", skill_md_bytes, "text/markdown")]
+ files=[("wrong/SKILL.md", skill_md_bytes, "text/markdown")]
+ files=[("my-skill/SKILL.md", skill_md_bytes, "text/markdown")]
betas: Optional header to specify the beta version(s) you want to use.
@@ -105,6 +115,12 @@ def create(
timeout: Override the client-level default timeout for this request, in seconds
"""
+ if is_given(files) and files is not None:
+ _title = display_title if is_given(display_title) and display_title is not None else None
+ try:
+ files = normalize_skill_files(list(files), display_title=_title)
+ except ValueError:
+ pass # No SKILL.md or name — let the API surface a descriptive error.
extra_headers = {
**strip_not_given(
{
@@ -364,8 +380,17 @@ async def create(
files: Files to upload for the skill.
- All files must be in the same top-level directory and must include a SKILL.md
- file at the root of that directory.
+ Every path must be prefixed with a top-level directory whose name
+ **exactly matches** the ``name:`` field in the ``SKILL.md``
+ frontmatter (e.g. ``my-skill/SKILL.md``,
+ ``my-skill/scripts/tool.py``). This is handled **automatically**
+ — bare paths like ``"SKILL.md"`` are rewritten before the request
+ is sent, so callers never need to construct the prefix manually::
+
+ # All of these are equivalent — the SDK normalizes them:
+ files=[("SKILL.md", skill_md_bytes, "text/markdown")]
+ files=[("wrong/SKILL.md", skill_md_bytes, "text/markdown")]
+ files=[("my-skill/SKILL.md", skill_md_bytes, "text/markdown")]
betas: Optional header to specify the beta version(s) you want to use.
@@ -377,6 +402,12 @@ async def create(
timeout: Override the client-level default timeout for this request, in seconds
"""
+ if is_given(files) and files is not None:
+ _title = display_title if is_given(display_title) and display_title is not None else None
+ try:
+ files = normalize_skill_files(list(files), display_title=_title)
+ except ValueError:
+ pass # No SKILL.md or name — let the API surface a descriptive error.
extra_headers = {
**strip_not_given(
{
diff --git a/tests/lib/tools/test_skills.py b/tests/lib/tools/test_skills.py
index ad3f2e0d2..c7be3a7dc 100644
--- a/tests/lib/tools/test_skills.py
+++ b/tests/lib/tools/test_skills.py
@@ -23,7 +23,13 @@
import pytest
-from anthropic.lib.tools._skills import _strip_top, _archive_top_dir, _extract_skill_archive
+from anthropic.lib.tools._skills import (
+ _strip_top,
+ _archive_top_dir,
+ normalize_skill_files,
+ _extract_skill_archive,
+ _parse_skill_name_from_frontmatter,
+)
def _make_zip(path: Path, entries: dict[str, bytes]) -> None:
@@ -194,3 +200,137 @@ def test_extract_drops_setuid_setgid_sticky(make: ArchiveModeMaker, tmp_path: Pa
# A non-executable member with setuid set must also drop the bit.
assert doc & 0o7000 == 0
assert doc == 0o644
+
+
+# ---------------------------------------------------------------------------
+# _parse_skill_name_from_frontmatter
+# ---------------------------------------------------------------------------
+
+
+def test_parse_frontmatter_returns_name() -> None:
+ md = b"---\nname: my-skill\ndescription: x\n---\n"
+ assert _parse_skill_name_from_frontmatter(md) == "my-skill"
+
+
+def test_parse_frontmatter_returns_none_when_missing() -> None:
+ assert _parse_skill_name_from_frontmatter(b"---\ndescription: no name\n---\n") is None
+
+
+def test_parse_frontmatter_returns_none_no_block() -> None:
+ assert _parse_skill_name_from_frontmatter(b"just content, no front matter") is None
+
+
+# ---------------------------------------------------------------------------
+# normalize_skill_files
+# ---------------------------------------------------------------------------
+
+_SKILL_MD = b"---\nname: my-skill\ndescription: Test skill.\n---\n\nSkill content."
+
+
+def test_normalize_skill_files_adds_prefix_to_bare_names() -> None:
+ files = [
+ ("SKILL.md", _SKILL_MD, "text/markdown"),
+ ("scripts/tool.py", b"print(1)", "text/plain"),
+ ]
+ result = normalize_skill_files(files)
+ assert result[0][0] == "my-skill/SKILL.md"
+ assert result[1][0] == "my-skill/scripts/tool.py"
+
+
+def test_normalize_skill_files_replaces_wrong_prefix() -> None:
+ """Wrong top-level prefix is stripped and replaced — not double-prefixed."""
+ files = [
+ ("wrong-dir/SKILL.md", _SKILL_MD, "text/markdown"),
+ ("wrong-dir/scripts/tool.py", b"x", "text/plain"),
+ ]
+ result = normalize_skill_files(files)
+ assert result[0][0] == "my-skill/SKILL.md"
+ assert result[1][0] == "my-skill/scripts/tool.py"
+
+
+def test_normalize_skill_files_leaves_correct_prefix_unchanged() -> None:
+ files = [
+ ("my-skill/SKILL.md", _SKILL_MD, "text/markdown"),
+ ("my-skill/scripts/tool.py", b"x", "text/plain"),
+ ]
+ result = normalize_skill_files(files)
+ assert result[0][0] == "my-skill/SKILL.md"
+ assert result[1][0] == "my-skill/scripts/tool.py"
+
+
+def test_normalize_skill_files_is_idempotent() -> None:
+ """Applying normalization twice must produce the same result."""
+ files = [
+ ("SKILL.md", _SKILL_MD, "text/markdown"),
+ ("scripts/tool.py", b"x", "text/plain"),
+ ]
+ once = normalize_skill_files(files)
+ twice = normalize_skill_files(once)
+ assert once == twice
+
+
+def test_normalize_skill_files_preserves_extra_tuple_fields() -> None:
+ headers: dict[str, str] = {"X-Custom": "val"}
+ files = [
+ ("SKILL.md", _SKILL_MD, "text/markdown", headers),
+ ]
+ result = normalize_skill_files(files)
+ assert result[0] == ("my-skill/SKILL.md", _SKILL_MD, "text/markdown", headers)
+
+
+def test_normalize_skill_files_two_tuple_entries() -> None:
+ """2-tuples (no MIME type) are handled correctly."""
+ files = [
+ ("SKILL.md", _SKILL_MD),
+ ("README.md", b"# readme"),
+ ]
+ result = normalize_skill_files(files) # type: ignore[arg-type]
+ assert result[0][0] == "my-skill/SKILL.md"
+ assert result[1][0] == "my-skill/README.md"
+
+
+def test_normalize_skill_files_display_title_fallback() -> None:
+ """When SKILL.md has no name: field, display_title is used as fallback."""
+ no_name_md = b"---\ndescription: No name.\n---\n\ncontent."
+ files = [("SKILL.md", no_name_md, "text/markdown")]
+ result = normalize_skill_files(files, display_title="My Skill")
+ assert result[0][0] == "my-skill/SKILL.md"
+
+
+def test_normalize_skill_files_display_title_special_chars() -> None:
+ no_name_md = b"---\ndescription: x\n---\n"
+ files = [("SKILL.md", no_name_md, "text/markdown")]
+ result = normalize_skill_files(files, display_title="My Skill v2!")
+ assert result[0][0] == "my-skill-v2/SKILL.md"
+
+
+def test_normalize_skill_files_raises_without_skill_md() -> None:
+ files = [("other.py", b"x", "text/plain")]
+ with pytest.raises(ValueError, match="SKILL.md"):
+ normalize_skill_files(files)
+
+
+def test_normalize_skill_files_raises_on_missing_name_no_fallback() -> None:
+ """ValueError is raised when frontmatter has no name: and no display_title."""
+ no_name_md = b"---\ndescription: No name.\n---\n\ncontent."
+ files = [("SKILL.md", no_name_md, "text/markdown")]
+ with pytest.raises(ValueError, match="name"):
+ normalize_skill_files(files)
+
+
+def test_normalize_skill_files_accepts_bytes_io_content() -> None:
+ skill_md_io = io.BytesIO(_SKILL_MD)
+ files = [("SKILL.md", skill_md_io, "text/markdown")]
+ result = normalize_skill_files(files)
+ assert result[0][0] == "my-skill/SKILL.md"
+ # BytesIO must be seeked back so the SDK can still send the bytes.
+ assert skill_md_io.read() == _SKILL_MD
+
+
+def test_normalize_skill_files_accepts_path_content(tmp_path: Path) -> None:
+ """PathLike file content is read and the stream left intact for upload."""
+ skill_md_path = tmp_path / "SKILL.md"
+ skill_md_path.write_bytes(_SKILL_MD)
+ files = [("SKILL.md", skill_md_path, "text/markdown")]
+ result = normalize_skill_files(files) # type: ignore[arg-type]
+ assert result[0][0] == "my-skill/SKILL.md"