// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Where skill documents come from.
///
/// The specification says a skill is a directory, and this is that directory
/// seen as two questions: which skills are here, and what is in this file. A
/// client with skills in a tarball, a database, or an HTTP cache implements the
/// two methods and gets [`discover`], the three-tier loading, and everything
/// else unchanged — nothing above this trait knows what a filesystem is.
///
/// `list` returns skill roots: the directories that hold a document, in the
/// order the client wants them offered to a model. `read` returns the bytes of
/// a path. Both speak of paths with `/` between segments whatever the platform
/// uses, so the same report reads the same everywhere.
pub(open) trait Source {
/// The skill roots this source offers.
async fn list(Self) -> Array[String]
/// The bytes of one file. The path is a skill root from `list` joined with a
/// relative name, or a root the caller passed to [`load`].
async fn read(Self, path : String) -> Bytes
}
///|
/// Every skill a source offers, read and parsed.
///
/// A source that lists a directory with no document is a broken source, not an
/// empty one, so `discover` refuses rather than skipping it: silently offering
/// a model a skill it cannot open is how an agent comes to invent a tool it
/// does not have.
pub async fn discover(source : &Source) -> Array[Skill] {
let roots = source.list()
let skills = []
for root in roots {
skills.push(load(source, root~))
}
skills
}
///|
/// One skill, read from the source.
///
/// The document is `SKILL.md`, or `skill.md` for a directory written on a
/// system that folds case, which the specification allows readers to accept.
pub async fn load(source : &Source, root~ : String) -> Skill {
let bytes = match try_read(source, join_path(root, "SKILL.md")) {
Some(bytes) => bytes
None =>
match try_read(source, join_path(root, "skill.md")) {
Some(bytes) => bytes
None => raise Refused::NoDocument
}
}
let text = @utf8.decode(bytes, ignore_bom=false) catch {
@utf8.Malformed(rest) =>
raise Refused::NotUtf8(bytes.length() - rest.length())
}
parse(text, root~)
}
///|
/// `read` with the error kept as a value, so a caller can decide what a failure
/// means instead of guessing from a raised error it cannot inspect.
async fn try_read(source : &Source, path : String) -> Bytes? {
match (Ok(source.read(path)) catch { error => Err(error) }) {
Ok(bytes) => Some(bytes)
Err(_) => None
}
}