Skip to content

listSkills — inventory and skip reasons

Rust · crate toolnexus · SPEC §3

pub fn list_skills(opts: &LoadSkillsOptions) -> SkillInventory
pub struct SkillInventory { pub skills: Vec<SkillInfo>, pub skipped: Vec<SkillSkip> }

Enumerate discovered skills and, crucially, the ones that were skipped and why.

To show what was found and why something was skipped (missing name, malformed frontmatter, duplicate, unreadable) — for a settings screen or a lint step — without building a tool.

Use this rather than load_skills_with(..).skipped when you do not want the tool or the filter applied: it is the unfiltered inventory.

use std::path::Path;
use toolnexus::skills::{LoadSkillsOptions, list_skills};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path.
let dir = Path::new(&std::env::var("TOOLNEXUS_REPO")?).join("examples/skills");
let inv = list_skills(&LoadSkillsOptions { dirs: vec![dir], ..Default::default() });
assert_eq!(inv.skills.len(), 1);
assert_eq!(inv.skills[0].name, "hello-world");
// A clean directory skips nothing.
assert!(inv.skipped.is_empty(), "{:?}", inv.skipped);
println!("ok: {} | skipped: {}", inv.skills[0].name, inv.skipped.len());
Ok(())
}

Four typed reasons, and this is how each one shows up.

use std::path::Path;
use toolnexus::skills::{LoadSkillsOptions, SkillSkipReason, list_skills};
fn write(root: &Path, name: &str, body: &str) {
std::fs::create_dir_all(root.join(name)).unwrap();
std::fs::write(root.join(name).join("SKILL.md"), body).unwrap();
}
fn main() {
let root = std::env::temp_dir().join(format!("skills-list-{}", std::process::id()));
write(&root, "good", "---\nname: good\ndescription: Fine.\n---\n\nBody.\n");
// Fences present, YAML invalid — and `[` opens a construct the lenient
// rescue refuses, so no name can be recovered.
write(&root, "broken", "---\nname: [oops\n---\n\nBody.\n");
// Parses, but has no name — nothing to call it by.
write(&root, "unnamed", "---\ndescription: No name here.\n---\n\nBody.\n");
let inv = list_skills(&LoadSkillsOptions { dirs: vec![root.clone()], ..Default::default() });
std::fs::remove_dir_all(&root).ok();
assert_eq!(inv.skills.len(), 1);
assert_eq!(inv.skills[0].name, "good");
let reasons: Vec<SkillSkipReason> = inv.skipped.iter().map(|s| s.reason).collect();
assert!(reasons.contains(&SkillSkipReason::MalformedFrontmatter), "{:?}", inv.skipped);
assert!(reasons.contains(&SkillSkipReason::MissingName), "{:?}", inv.skipped);
// Reasons are typed variants with stable string values.
assert_eq!(SkillSkipReason::MalformedFrontmatter.as_str(), "malformed-frontmatter");
println!("ok: 1 skill, {} skips", inv.skipped.len());
}

3. Every source, duplicate detection, and authoring the allowlist

Section titled “3. Every source, duplicate detection, and authoring the allowlist”

Directories and data skills go into the same inventory, and the second skill to claim a name is reported as duplicate-name — first one wins.

use std::collections::HashMap;
use std::path::Path;
use toolnexus::skills::{LoadSkillsOptions, SkillDef, SkillSkipReason, list_skills, load_skills_with};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let shared = Path::new(&std::env::var("TOOLNEXUS_REPO")?).join("examples/skills");
let opts = LoadSkillsOptions {
dirs: vec![shared],
skills: vec![
SkillDef {
name: "deploy".into(),
description: "Ship the service to prod.".into(),
content: "Run the taskfile.".into(),
resources: vec!["skill://deploy/checklist.md".into()],
..Default::default()
},
// Collides with the fixture on disk — dirs are collected first, so this loses.
SkillDef { name: "hello-world".into(), description: "A shadow.".into(), content: "Ignored.".into(), ..Default::default() },
// No name at all.
SkillDef { description: "Anonymous.".into(), content: "Ignored.".into(), ..Default::default() },
],
// filter is accepted but has NO effect on the inventory.
filter: HashMap::from([("deploy".to_string(), true)]),
..Default::default()
};
let inv = list_skills(&opts);
let mut names: Vec<&str> = inv.skills.iter().map(|s| s.name.as_str()).collect();
names.sort();
assert_eq!(names, ["deploy", "hello-world"], "unfiltered inventory expected");
let count = |r| inv.skipped.iter().filter(|s| s.reason == r).count();
assert_eq!(count(SkillSkipReason::DuplicateName), 1);
assert_eq!(count(SkillSkipReason::MissingName), 1);
// The fixture on disk won: hello-world is still the fs-sourced one.
for s in &inv.skills {
match s.name.as_str() {
"hello-world" => assert_eq!(s.origin, "fs"),
"deploy" => assert_eq!(s.origin, "logical"),
_ => {}
}
}
// Now author the allowlist from what you actually found, and load for real.
let allow: HashMap<String, bool> =
inv.skills.iter().map(|s| (s.name.clone(), s.name == "deploy")).collect();
let src = load_skills_with(LoadSkillsOptions { filter: allow, ..opts.clone() });
assert_eq!(src.skills.len(), 1);
println!("ok: {names:?} | skipped: {}", inv.skipped.len());
Ok(())
}

LoadSkillsOptions — the same struct load_skills_with takes:

Field Type Effect on list_skills
dirs Vec<PathBuf> Roots walked for **/SKILL.md. A missing root contributes nothing.
skills Vec<SkillDef> Skills supplied as data. Collected after dirs, so directories win a name clash.
filter HashMap<String, bool> Ignored — the inventory is always unfiltered.
sample_limit i64 Ignored — no tool is built, so nothing is sampled.

SkillSkipReason — the typed values in skipped:

Variant as_str() Means
MissingName missing-name Frontmatter parsed but carried no name (including “no frontmatter at all”).
MalformedFrontmatter malformed-frontmatter --- fences present, YAML inside failed to parse.
DuplicateName duplicate-name Another candidate already claimed that name; the first one is kept.
Unreadable unreadable The SKILL.md could not be read (permissions, broken link).
  • load_skills — Glob a skills directory and expose one skill tool with progressive disclosure.
  • load_skills_with — Supply skills as data or from a lazy provider, and narrow them per agent with an allowlist.