Toolnexus.Skill.list
Elixir · package toolnexus · SPEC §3 · elixir/lib/toolnexus/skill.ex
@spec list(String.t() | [String.t()] | keyword() | map()) :: %{skills: [Toolnexus.Skill.Info.t()], skipped: [%{location: String.t(), reason: String.t()}]}def list(input)Runs the same discovery and parsing as Toolnexus.Skill.load/1 and hands
back the result as data — the skills that parsed, and the candidates that were rejected together
with a typed reason. No skill tool is built and no prompt is rendered.
The skipped list is the reason this function exists. load/1 silently drops a malformed
SKILL.md; list/1 tells you the path and the reason, so a broken skill fails your CI instead of
quietly vanishing from the model’s catalog.
When to use it
Section titled “When to use it”- A startup or CI check — assert every
SKILL.mdin the repo parsed, so a typo in the frontmatter is a build failure rather than a mysteriously absent capability. - Authoring an allowlist — enumerate what exists, then feed names to
load/1’s:filter. The inventory is deliberately unfiltered for exactly this. - Tooling and dashboards — a
skills listCLI command, an admin page, a doctor command.
Why this and not the alternative
Section titled “Why this and not the alternative”list/1 accepts every input shape load/1 does, including :skills and :provider, so the same
configuration can be validated and then loaded.
Examples
Section titled “Examples”1. Inventory the shared fixture
Section titled “1. Inventory the shared fixture”alias Toolnexus.Skill
# TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path.repo = System.get_env("TOOLNEXUS_REPO") || "."dir = Path.join([repo, "examples", "skills"])
%{skills: skills, skipped: skipped} = Skill.list(dir)
# Nothing was rejected — this is the healthy shape.[] = skipped
[info] = skillstrue = info.name == "hello-world"true = String.starts_with?(info.description, "A tiny example skill.")true = String.ends_with?(info.location, "hello-world/SKILL.md")true = info.origin == :fs# The instruction body is already parsed and available.true = String.contains?(info.content, "# Hello World Skill")
IO.puts("ok: #{length(skills)} skill(s), #{length(skipped)} skipped")2. The skip reasons, seen on a deliberately broken tree
Section titled “2. The skip reasons, seen on a deliberately broken tree”Four things can go wrong. Three of them are visible here; "unreadable" is the fourth (a SKILL.md
whose bytes cannot be read — permissions, a dangling symlink).
alias Toolnexus.Skill
root = Path.join(System.tmp_dir!(), "toolnexus-docs-skills-#{System.unique_integer([:positive])}")
write = fn sub, text -> path = Path.join([root, sub, "SKILL.md"]) File.mkdir_p!(Path.dirname(path)) File.write!(path, text)end
# Fine.write.("greet", "---\nname: greet\ndescription: Say hello\n---\n\nBody.\n")# Frontmatter fences present but the YAML does not parse.write.("broken", "---\nname: [unclosed\n---\n\nBody.\n")# Parses, but there is no `name` key.write.("nameless", "---\ndescription: No name here\n---\n\nBody.\n")# A second skill claiming a name already taken — first one wins.write.("clash", "---\nname: greet\ndescription: Another greet\n---\n\nBody.\n")
%{skills: skills, skipped: skipped} = Skill.list(root)
# Exactly one survivor, and it is unambiguous which name won.1 = length(skills)true = Enum.map(skills, & &1.name) == ["greet"]
reasons = skipped |> Enum.map(& &1.reason) |> Enum.sort()true = reasons == ["duplicate-name", "malformed-frontmatter", "missing-name"]
# Every skip carries the offending path, so an error message can name the file.true = Enum.all?(skipped, &String.ends_with?(&1.location, "SKILL.md"))
File.rm_rf!(root)
IO.puts("ok: 1 kept, skipped: #{Enum.join(reasons, ", ")}")3. The full surface — validate a whole configuration, then load it
Section titled “3. The full surface — validate a whole configuration, then load it”list/1 sees data-supplied and provider-supplied skills too, and it never applies :filter. That
combination is what makes it the right tool for authoring an allowlist.
alias Toolnexus.Skill
repo = System.get_env("TOOLNEXUS_REPO") || "."dir = Path.join([repo, "examples", "skills"])
config = [ dirs: [dir], skills: [ %{name: "triage", description: "Triage an incoming bug report", content: "1. Reproduce it."}, %{name: "release", description: "Cut a release", content: "1. Bump the version."}, # No name — this becomes a skip, not a crash. %{description: "Anonymous", content: "..."} ]]
%{skills: skills, skipped: skipped} = Skill.list(config)
names = skills |> Enum.map(& &1.name) |> Enum.sort()true = names == ["hello-world", "release", "triage"]true = Enum.map(skipped, & &1.reason) == ["missing-name"]
# Data-sourced skills get a logical base and never touch disk.release = Enum.find(skills, &(&1.name == "release"))true = release.origin == :logicaltrue = release.base == "skill://release/"
# Directory-sourced ones keep their real path.hello = Enum.find(skills, &(&1.name == "hello-world"))true = hello.origin == :fs
# A CI gate: nothing may be silently dropped.gate = fn %{skipped: s} -> if s == [], do: :ok, else: {:error, Enum.map(s, & &1.reason)} end{:error, ["missing-name"]} = gate.(%{skipped: skipped})
# Now author the allowlist from the inventory and load only what this agent gets.allow = Map.new(["hello-world", "triage"], &{&1, true})source = Skill.load(Keyword.put(config, :filter, allow))
true = source.skills |> Enum.map(& &1.name) |> Enum.sort() == ["hello-world", "triage"]# list/1 stays UNFILTERED — that is the point.true = length(Skill.list(Keyword.put(config, :filter, allow)).skills) == 3
IO.puts("ok: inventory #{Enum.join(names, ", ")} -> loaded #{length(source.skills)}")Options
Section titled “Options”Identical to Skill.load/1 — a binary, a list of binaries, or a map /
keyword list:
| Option | Effect on list/1 |
|---|---|
:dirs |
Roots walked for **/SKILL.md. A missing root warns on stderr and contributes nothing. |
:skills |
Data-supplied skills; validated and included, origin: :logical. |
:provider |
Resolved once. A failing provider is isolated with a warning, not raised. |
:filter |
Ignored — the inventory is intentionally unfiltered. |
:sample_limit |
Ignored — no skill tool is built, so nothing is sampled. |
What you get back
Section titled “What you get back”| Key | Type | What it is |
|---|---|---|
:skills |
[%Skill.Info{}] |
Parsed skills, in discovery order, deduped by name (first wins). |
:skipped |
[%{location:, reason:}] |
One entry per rejected candidate. |
Skip reasons
Section titled “Skip reasons”| Reason | Cause |
|---|---|
"missing-name" |
Frontmatter parsed but name is absent or empty. |
"malformed-frontmatter" |
The --- fences are present but the YAML between them fails to parse. |
"duplicate-name" |
An earlier candidate already claimed that name. |
"unreadable" |
The SKILL.md could not be read off disk. |
See also
Section titled “See also”Toolnexus.Skill.load— the runtime path that builds theskilltoolToolnexus.Skill.loadwith data and filters —:skills,:provider,:filterToolnexus.create_toolkit— where the loaded skills end up