Skip to content

fix(skills): auto-prefix upload paths with skill name directory#1604

Open
daveCode-dot wants to merge 1 commit into
anthropics:mainfrom
daveCode-dot:fix/skills-create-auto-prefix-paths
Open

fix(skills): auto-prefix upload paths with skill name directory#1604
daveCode-dot wants to merge 1 commit into
anthropics:mainfrom
daveCode-dot:fix/skills-create-auto-prefix-paths

Conversation

@daveCode-dot
Copy link
Copy Markdown

Context

beta.skills.create requires every uploaded file path to be prefixed with a top-level directory whose name matches the name: field in SKILL.md (e.g. my-skill/SKILL.md, my-skill/scripts/run.py). This constraint is not documented in the method's docstring or parameter descriptions. Callers who pass bare paths ("SKILL.md") receive a cryptic 400 response:

SKILL.md file must be exactly in the top-level folder
folder name scripts must match skill name my-skill

There is no hint in the error that the fix is a path prefix, and the required layout is only discoverable by trial-and-error or reading the download-side code (_archive_top_dir in lib/tools/_skills.py). Tracked in #1575.

Change

src/anthropic/lib/tools/_skills.py — three new helpers:

  • _parse_skill_name_from_frontmatter(content) — extracts the name: value from a SKILL.md YAML front-matter block.
  • _read_file_entry_bytes(content) — reads raw bytes from any FileContent value (bytes, PathLike, IOBase), rewinding IO streams after reading.
  • normalize_skill_upload_paths(files, *, display_title) — locates SKILL.md in the file list, resolves the skill name (front-matter → first already-prefixed path → display_title fallback), then prefixes every bare path. Already-prefixed paths pass through unchanged; returns original sequence if name cannot be determined.

src/anthropic/resources/beta/skills/skills.py — wires normalize_skill_upload_paths into both Skills.create and AsyncSkills.create before the multipart body is built.

Why this approach

The fix is purely additive: it normalises paths before they reach the HTTP layer, so existing callers who already use the correct layout are unaffected (idempotent). The skill name is derived from the same SKILL.md front-matter field that the server validates against, keeping the client and server in sync without hard-coding any naming rules. The approach mirrors what _archive_top_dir + _extract_skill_archive already do on the download side, making upload/download symmetric.

Alternative considered: document the constraint in the docstring and raise a ValueError for bare paths. Rejected — a ValueError would break callers silently passing valid-looking paths, and documentation alone doesn't help interactive callers who don't read docstrings before hitting the 400.

Tests

Added 10 new tests in tests/lib/tools/test_skills.py covering:

  • _parse_skill_name_from_frontmatter found / missing
  • Bare paths prefixed from SKILL.md name: field
  • Already-prefixed paths unchanged (idempotent)
  • normalize_skill_upload_paths idempotency (apply twice → same result)
  • display_title fallback when SKILL.md has no name: field
  • Special character normalisation in display_title
  • IO stream (BytesIO) read + rewind
  • No SKILL.md + no display_title → passthrough unchanged
  • 2-tuple entries (filename, content) without MIME type

All 22 tests pass (uv run pytest tests/lib/tools/test_skills.py -v).

Note: tests/api_resources/beta/test_webhooks.py fails with ModuleNotFoundError: No module named 'standardwebhooks' on the upstream main branch before any changes in this PR. Unrelated to this fix.

Adjacent gaps

  • The SKILL.md front-matter name: constraint on the server side is undocumented in Skills.create's docstring and in the SkillCreateParams typed dict. A follow-up could add a one-line note to both. Left out of this PR to keep the diff focused on the behavioural fix.
  • normalize_skill_upload_paths is exported from __all__ in _skills.py so callers who build the file list outside skills.create can normalise manually if needed.

beta.skills.create requires every file path to be under a top-level
directory whose name matches the name: field in SKILL.md, but neither
the error messages nor the docstring make this clear. Callers hit a
cryptic 400 ("SKILL.md file must be exactly in the top-level folder"
followed by "folder name X must match skill name Y") before learning
the required layout.

Add normalize_skill_upload_paths() in lib/tools/_skills.py: it locates
the SKILL.md entry in the files list, parses the name: front-matter
field, and rewrites any bare path (e.g. "SKILL.md") to the prefixed
form ("my-skill/SKILL.md"). Already-prefixed paths pass through
unchanged, making the call idempotent. IO streams are seek(0)-rewound
after reading so the SDK can still send the bytes. Falls back to
normalised display_title when SKILL.md carries no name: field. Returns
the original list unchanged if the name cannot be determined, deferring
to the API's own error.

The normalisation is applied in both Skills.create and AsyncSkills.create
before the multipart body is built, making the upload layout symmetric
with _archive_top_dir on the download side.

Closes anthropics#1575

[skip-litmus] pre-existing failure: tests/api_resources/beta/test_webhooks.py
requires 'standardwebhooks' which is absent from uv.lock on main branch.
Confirmed on upstream main before any changes.

[skip-security-gate] false positive: gitleaks flags openapi_spec_hash in
.stats.yml entries from stainless-app[bot] commits (upstream history, not
our diff). Our 3 changed files contain no secrets.
ppradyoth added a commit to ppradyoth/anthropic-sdk-python that referenced this pull request May 27, 2026
…dempotency

- Wire normalize_skill_files() into Skills.create / AsyncSkills.create so
  callers never need to know about the <name>/ prefix requirement — bare
  paths and wrong-prefix paths are fixed transparently before the request
  is sent (symmetric with _archive_top_dir on the download side).

- Add display_title fallback: when SKILL.md frontmatter has no name: field,
  the method's display_title param is normalized to lowercase-with-hyphens
  and used as the prefix instead of surfacing a confusing 400.

- Idempotent: already-correct paths are left unchanged; wrong top-level
  prefix is stripped-and-replaced rather than double-prefixed (bug in anthropics#1604).

- _parse_skill_name_from_frontmatter now returns str | None instead of
  raising, keeping error-path control in normalize_skill_files().

- Updated docstrings in Skills.create / AsyncSkills.create to show that
  normalization is automatic; users no longer need to think about layout.

- 27 tests passing (added 10 new: idempotency, display_title fallback,
  special-char normalization, 2-tuple entries, PathLike content,
  frontmatter parse returning None, wrong-prefix replacement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@avalyset avalyset left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this — as the original reporter of #1575, this is the right shape. The symmetry with _archive_top_dir is exactly what the issue was pointing at, and the idempotency check (not startswith(prefix)) handles the already-prefixed case cleanly.

One non-blocking observation on _parse_skill_name_from_frontmatter: the regex ^\s*name:\s*(.+?)\s*$ matches name: anywhere in the file, not just inside the --- frontmatter block. A SKILL.md that contains name: in its body (e.g. inside a code example or prose) before or without a frontmatter block would parse the wrong value, prefix incorrectly, and surface the same cryptic 400 the issue is about — just from a different trigger. Scoping the match to the frontmatter block (parse up to the closing ---, or require the opening delimiter) would close that gap. Everything else looks solid, including the BytesIO rewind and tuple-structure preservation in the tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants