fix(skills): auto-prefix upload paths with skill name directory#1604
fix(skills): auto-prefix upload paths with skill name directory#1604daveCode-dot wants to merge 1 commit into
Conversation
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.
…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>
avalyset
left a comment
There was a problem hiding this comment.
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.
Context
beta.skills.createrequires every uploaded file path to be prefixed with a top-level directory whose name matches thename:field inSKILL.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: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_dirinlib/tools/_skills.py). Tracked in #1575.Change
src/anthropic/lib/tools/_skills.py— three new helpers:_parse_skill_name_from_frontmatter(content)— extracts thename:value from a SKILL.md YAML front-matter block._read_file_entry_bytes(content)— reads raw bytes from anyFileContentvalue (bytes,PathLike,IOBase), rewinding IO streams after reading.normalize_skill_upload_paths(files, *, display_title)— locatesSKILL.mdin the file list, resolves the skill name (front-matter → first already-prefixed path →display_titlefallback), 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— wiresnormalize_skill_upload_pathsinto bothSkills.createandAsyncSkills.createbefore 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.mdfront-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_archivealready do on the download side, making upload/download symmetric.Alternative considered: document the constraint in the docstring and raise a
ValueErrorfor bare paths. Rejected — aValueErrorwould 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.pycovering:_parse_skill_name_from_frontmatterfound / missingname:fieldnormalize_skill_upload_pathsidempotency (apply twice → same result)display_titlefallback when SKILL.md has noname:fielddisplay_titleBytesIO) read + rewinddisplay_title→ passthrough unchangedAll 22 tests pass (
uv run pytest tests/lib/tools/test_skills.py -v).Adjacent gaps
SKILL.mdfront-mattername:constraint on the server side is undocumented inSkills.create's docstring and in theSkillCreateParamstyped 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_pathsis exported from__all__in_skills.pyso callers who build the file list outsideskills.createcan normalise manually if needed.