Reading an Agent Skills bundle, and refusing one that cannot be stored as it is.
Every entry point -- the console's upload, a folder on disk, a tar archive -- arrives here, so what counts as a skill is decided in exactly one place. What comes out is a list of skills, each a name and its files at the paths they had inside the bundle, and nothing has been written anywhere.
What a source may look like
- one skill directory, with a
SKILL.mdat its root, and - a collection of immediate skill directories, each with a
SKILL.md.
A folder that holds a SKILL.md at its own root is that one skill, and takes
its name from the folder. An archive may add one common wrapper directory
above the skills, which is how a repository is usually archived; it is
recognised only when every member sits beneath it and the skills are inside
it. The directory name of a skill is its name; nothing inside SKILL.md is
parsed, so a skill is preserved exactly as its author wrote it.
What is refused
An unsafe path (absolute, traversing, backslashed, carrying control
characters, or holding an empty segment), a non-regular file, a symbolic or
hard link, a duplicate path, a path stored both as a file and a directory, a
skill with no SKILL.md, and a file that is not valid UTF-8. Files are read
as text because that is what the store holds; a binary asset is reported
rather than mangled.
What is set aside
macOS metadata travels inside user folders -- AppleDouble ._ sidecars
(materialized beside every file on external volumes) and .DS_Store files.
It is never skill content, so entries whose base name is one are set aside
before they can cost budget, trip validation, or be stored. A source holding
only metadata is still refused, as a source with no skills.
Archive members are read in memory and never extracted to disk, and the two
limits in limits/0 bound the entries and the expanded bytes of one source,
so a small archive cannot cost an unbounded amount of work.
Why the limits are here and not in configuration
The console and the Mix task have to accept the same bundles, and a limit
that could be set differently on each would make that untrue. They are
therefore one set of numbers, published by limits/0 so that a caller
configures its own caps to match rather than restating them.
Summary
Types
One file of a skill, at path below the skill's root.
A skill ready to be stored: its name, and every file it holds.
Where a bundle comes from.
One file as a browser sent it: a client-relative path and its bytes.
Functions
The bounds one import accepts: entries, and expanded bytes.
Reads a source into the skills it holds.
Why an import was refused, as a sentence an operator can act on.
Types
One file of a skill, at path below the skill's root.
A skill ready to be stored: its name, and every file it holds.
Where a bundle comes from.
:path is a directory or an archive on disk, :archive the bytes of one, and
:uploads the files of a browser directory selection.
One file as a browser sent it: a client-relative path and its bytes.
Functions
@spec limits() :: %{max_entries: pos_integer(), max_bytes: pos_integer()}
The bounds one import accepts: entries, and expanded bytes.
Reads a source into the skills it holds.
A refusal is about the whole source, never about one file of it. Nothing is
written while a source is being read, so a rejected bundle cannot leave half
of itself stored, and message/1 turns whatever comes back into a sentence
for the operator.
Why an import was refused, as a sentence an operator can act on.
Every refusal an import can report is answered here, the destination's as well as the source's, so a caller has one way to turn a reason into something worth showing rather than a format of its own per surface.