SkillSource.ListSkills
C# · package Toolnexus · SPEC §3 · SkillSource.cs
public static SkillInventory ListSkills(SkillSource.LoadOptions opts)Runs exactly the same discovery as Load — the same directory walk,
the same frontmatter parse, the same duplicate handling — but instead of building a skill tool it
hands back a report: the skills it accepted, and a typed list of the candidates it rejected
with the reason for each.
Load throws none of that away quietly-but-visibly; it writes a line to stderr and moves on.
ListSkills gives you the same information as data you can assert on.
When to use it
Section titled “When to use it”- A skill silently isn’t there. You wrote a
SKILL.md, the model never sees it, and nothing obviously failed.Skippedtells you it wasmissing-nameormalformed-frontmatter. - Validate in CI. Fail the build when any candidate under
skills/was skipped. - Author an allowlist. The inventory is deliberately unfiltered, so it is the right input
for building the
Filtermap you then pass toLoadWith. - Show a picker. Render the available skills in a UI without wiring a toolkit.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. Inventory the shared fixture
Section titled “1. Inventory the shared fixture”using Toolnexus;
// TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path.var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? ".";
var inv = SkillSource.ListSkills(new SkillSource.LoadOptions{ Dirs = new[] { Path.Combine(repo, "examples", "skills") },});
if (inv.Skills.Count != 1) throw new Exception($"expected 1 skill, got {inv.Skills.Count}");if (inv.Skills[0].Name != "hello-world") throw new Exception(inv.Skills[0].Name);if (inv.Skipped.Count != 0) throw new Exception($"clean fixture should skip nothing: {string.Join(",", inv.Skipped.Select(s => s.Reason))}");
Console.WriteLine($"ok: {inv.Skills[0].Name} | skipped {inv.Skipped.Count}");Skills is a list of SkillInfo, not a dictionary — unlike SkillSource.Skills on a loaded
source. Order follows discovery order, so sort it yourself if you are printing it.
2. The reason a skill went missing
Section titled “2. The reason a skill went missing”Three candidate files, one of which is fine.
using Toolnexus;
var root = Path.Combine(Path.GetTempPath(), "tn-inv-" + Guid.NewGuid().ToString("N"));
void Skill(string folder, string body){ Directory.CreateDirectory(Path.Combine(root, folder)); File.WriteAllText(Path.Combine(root, folder, "SKILL.md"), body);}
Skill("good", """---name: gooddescription: A perfectly ordinary skill.---
Do the thing.""");
// Frontmatter parses, but there is no `name:` — nothing to call the skill by.Skill("nameless", """---description: I forgot to name myself.---
Do the thing.""");
// Fences are present but the YAML inside them does not parse.Skill("broken", "---\nname: \"unterminated\ndescription: nope\n---\n\nDo the thing.\n");
try{ var inv = SkillSource.ListSkills(new SkillSource.LoadOptions { Dirs = new[] { root } });
if (inv.Skills.Count != 1 || inv.Skills[0].Name != "good") throw new Exception($"skills: {string.Join(",", inv.Skills.Select(s => s.Name))}");
var reasons = inv.Skipped.Select(s => s.Reason).OrderBy(r => r, StringComparer.Ordinal).ToList(); if (reasons.Count != 2) throw new Exception($"skipped: {string.Join(",", reasons)}"); if (reasons[0] != SkillSource.SkillSkip.Malformed) throw new Exception(reasons[0]); if (reasons[1] != SkillSource.SkillSkip.MissingName) throw new Exception(reasons[1]);
// Every skip carries the absolute path of the offending file, so you can print it. if (!inv.Skipped.All(s => s.Location.EndsWith("SKILL.md"))) throw new Exception("location");
Console.WriteLine($"ok: kept {inv.Skills[0].Name} | skipped {string.Join(", ", reasons)}");}finally{ Directory.Delete(root, recursive: true);}3. Full surface — disk plus data, and authoring an allowlist
Section titled “3. Full surface — disk plus data, and authoring an allowlist”ListSkills accepts the same LoadOptions as LoadWith, including skills supplied as data. Use
the inventory to build the Filter and hand it straight back to LoadWith.
using Toolnexus;
var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? ".";
var opts = new SkillSource.LoadOptions{ Dirs = new[] { Path.Combine(repo, "examples", "skills") }, Skills = new[] { new SkillSource.SkillDef("triage", "Triage an inbound bug report.", "1. Reproduce.\n2. Label.", new[] { "rubric.md" }), // Same name as the on-disk fixture — disk wins, this one is reported as a duplicate. new SkillSource.SkillDef("hello-world", "A clashing definition.", "..."), // No name at all — rejected the same way a nameless file is. new SkillSource.SkillDef("", "Anonymous.", "..."), },};
var inv = SkillSource.ListSkills(opts);
var kept = inv.Skills.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();if (kept.Count != 2 || kept[0] != "hello-world" || kept[1] != "triage") throw new Exception($"kept: {string.Join(",", kept)}");
var reasons = inv.Skipped.Select(s => s.Reason).OrderBy(r => r, StringComparer.Ordinal).ToList();if (reasons.Count != 2) throw new Exception($"skipped: {string.Join(",", reasons)}");if (reasons[0] != SkillSource.SkillSkip.DuplicateName) throw new Exception(reasons[0]);if (reasons[1] != SkillSource.SkillSkip.MissingName) throw new Exception(reasons[1]);
// Data-sourced skills get a logical base instead of a file:// path.var triage = inv.Skills.First(s => s.Name == "triage");if (triage.Origin != "logical") throw new Exception(triage.Origin);if (triage.Location != "skill://triage/") throw new Exception(triage.Location);
// The inventory is UNFILTERED, which is what makes it a good allowlist source.opts.Filter = inv.Skills.ToDictionary(s => s.Name, s => s.Name == "triage");var narrowed = SkillSource.LoadWith(opts);if (narrowed.Skills.Count != 1 || !narrowed.Skills.ContainsKey("triage")) throw new Exception($"filtered: {string.Join(",", narrowed.Skills.Keys)}");
// The inventory itself does not change — re-listing still sees everything.if (SkillSource.ListSkills(opts).Skills.Count != 2) throw new Exception("inventory must ignore Filter");
Console.WriteLine($"ok: kept {string.Join(",", kept)} | skipped {string.Join(",", reasons)} | agent sees triage");What you get back
Section titled “What you get back”SkillInventory has two members:
| Member | Type | What it is |
|---|---|---|
Skills |
IReadOnlyList<SkillInfo> |
Accepted skills, unfiltered, in discovery order. |
Skipped |
IReadOnlyList<SkillSkip> |
Rejected candidates — Location + Reason. |
SkillSkip.Reason is one of four constants:
| Constant | Value | Cause |
|---|---|---|
SkillSkip.MissingName |
missing-name |
Frontmatter parsed but has no name. |
SkillSkip.Malformed |
malformed-frontmatter |
--- fences present, YAML inside failed to parse. |
SkillSkip.DuplicateName |
duplicate-name |
An earlier candidate already claimed that name. |
SkillSkip.Unreadable |
unreadable |
The file could not be read (permissions, broken link). |
LoadOptions is shared with LoadWith: Dirs, Skills, Filter, SampleLimit. Filter and
SampleLimit are ignored here — the inventory is always the complete picture.
See also
Section titled “See also”SkillSource.Load— the same discovery, returning theskilltoolSkillSource.LoadWith— takes the sameLoadOptions, appliesFilterToolkit.CreateAsync— where the resulting tool ends up