Skip to content

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.

  • A skill silently isn’t there. You wrote a SKILL.md, the model never sees it, and nothing obviously failed. Skipped tells you it was missing-name or malformed-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 Filter map you then pass to LoadWith.
  • Show a picker. Render the available skills in a UI without wiring a toolkit.
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.

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: good
description: 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");

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.