Skip to content

toolnexus.skill/list-skills

Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §3 · clojure/src/toolnexus/skill.cljc

(list-skills input)
;; input: every shape load-skills takes — a root, a seq of roots, skill
;; definitions, or the options map. :filter is accepted and IGNORED.
;;
;; => {:skills [{:name … :description … :location … :content …
;; :dir … :base … :origin "fs" | "logical"}]
;; :skipped [{:location "…/SKILL.md" :reason "malformed-frontmatter"}]}

list-skills runs the same discovery and parsing as toolnexus.skill/load-skills and hands the result back as a plain report: the skills that parsed, and the candidates that were rejected with a typed reason. No skill tool is built, no prompt is rendered, nothing is left open.

The :skipped vector is the reason this function exists. load-skills quietly drops a malformed SKILL.md — it is not going to refuse to build a toolkit over one bad file — so nothing tells you that a capability you wrote is missing from the model’s catalog. list-skills names the path and the reason, which is what turns that into a build failure.

It is deliberately unfiltered: :filter is stripped before discovery runs. The inventory is what you author an allowlist from, so filtering it would be circular. It also omits :by-name, the key skill-tool and execute-skill read — so a report cannot be handed to them by accident.

  • A CI gate — assert :skipped is empty, so a typo in frontmatter fails the build instead of vanishing a skill.
  • Authoring an allowlist — enumerate everything that exists, then feed names into load-skills’s :filter. This is what the unfiltered inventory is for.
  • Checking the frontmatter subset — this port’s parser is stricter than the other ports’ (see below), and this is how you find out which of your skills fall outside it.
  • Tooling — a skills list command, an admin page, a doctor command. The report is data with no side effects, so it is safe to call from anywhere.

Both take the same input, so one configuration value can be validated and then loaded without being written twice. Neither caches; both re-read the tree on every call, so call this once at startup rather than per request.

(require '[clojure.string :as str]
'[toolnexus.skill :as skill])
(def report (skill/list-skills "examples/skills"))
(:skipped report) ;=> []
(map :name (:skills report)) ;=> ("hello-world")
(let [hello (first (:skills report))]
(:origin hello) ;=> "fs"
(:location hello) ;=> "examples/skills/hello-world/SKILL.md"
;; the instruction body is already parsed and available
(str/includes? (:content hello) "# Hello World Skill")) ;=> true

The four skip reasons, and a gate over them

Section titled “The four skip reasons, and a gate over them”
(require '[toolnexus.skill :as skill])
(def report (skill/list-skills ["project/skills" "team/skills"]))
(map (juxt :reason :location) (:skipped report))
;; => (["malformed-frontmatter" "project/skills/broken/SKILL.md"]
;; ["missing-name" "project/skills/nameless/SKILL.md"]
;; ["duplicate-name" "team/skills/deploy/SKILL.md"])
(defn check-skills!
"Fail the build rather than ship an agent that silently lost a capability."
[roots]
(let [{:keys [skills skipped]} (skill/list-skills roots)]
(when (seq skipped)
(throw (ex-info "skills failed to load" {:skipped skipped})))
(count skills)))
Reason Cause
"missing-name" Frontmatter parsed, but name is absent or blank. A file with no frontmatter at all lands here too, as does a data definition without a :name.
"malformed-frontmatter" The --- fences are present and what sits between them is outside toolnexus.frontmatter’s accepted subset.
"duplicate-name" An earlier candidate already claimed that name. First wins.
"unreadable" The SKILL.md bytes could not be read — permissions, a dangling symlink.

Validate a whole configuration, then load it

Section titled “Validate a whole configuration, then load it”
(require '[toolnexus.skill :as skill])
(def config
{:dirs ["examples/skills"]
: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 — a skip, not a crash
{:description "Anonymous" :content ""}]})
(def report (skill/list-skills config))
(sort (map :name (:skills report))) ;=> ("hello-world" "release" "triage")
(map :reason (:skipped report)) ;=> ("missing-name")
;; Data-sourced skills carry a logical base and never touch disk.
(map (juxt :name :origin) (:skills report))
;; => (["hello-world" "fs"] ["triage" "logical"] ["release" "logical"])
;; Now author the allowlist from the inventory and load only what this agent gets.
(def loaded (skill/load-skills (assoc config :filter {"hello-world" true "triage" true})))
(sort (map :name (:skills loaded))) ;=> ("hello-world" "triage")
;; list-skills stays UNFILTERED even when the filter is present — that is the point.
(count (:skills (skill/list-skills (assoc config :filter {"hello-world" true})))) ;=> 3
Return key What it is
:skills Parsed skills in discovery order, deduped by name, never filtered. Each carries :name, :description, :location, :content, :dir, :base and :origin.
:skipped One {:location :reason} map per rejected candidate.