Skip to content

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.

  • A startup or CI check — assert every SKILL.md in 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 list CLI command, an admin page, a doctor command.

list/1 accepts every input shape load/1 does, including :skills and :provider, so the same configuration can be validated and then loaded.

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] = skills
true = 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 == :logical
true = 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)}")

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.
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.
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.