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"