Packages
nous
0.16.0
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/skill/loader.ex
defmodule Nous.Skill.Loader do
@moduledoc """
Loads skills from markdown files with YAML frontmatter.
Supports progressive disclosure: only frontmatter is parsed initially,
the full markdown body is loaded on first activation.
## File Format
---
name: code_review
description: Reviews code for quality and bugs
tags: [code, review]
group: review
activation: auto
allowed_tools: [read_file, grep]
priority: 100
---
You are a code review specialist...
"""
alias Nous.Skill
require Logger
@doc """
Load all `.md` skill files from a directory (recursively).
Returns skills with only frontmatter parsed (status: :discovered).
"""
# L-1: cap how many files we glob and how big each file may be.
# Symlinks are now skipped (lstat); a malicious symlink under the
# skills directory could otherwise cause this to read /etc/passwd
# or any other file the BEAM has permission to open.
@max_skill_files 1_000
@max_skill_file_bytes 5 * 1024 * 1024
@spec load_directory(String.t()) :: [Skill.t()]
def load_directory(path) do
path = Path.expand(path)
if File.dir?(path) do
Path.join(path, "**/*.md")
|> Path.wildcard()
|> Enum.take(@max_skill_files)
|> Enum.reject(&symlink_or_oversized?/1)
|> Enum.map(&load_file/1)
|> Enum.filter(fn
{:ok, _} ->
true
{:error, reason} ->
Logger.warning("Failed to load skill file: #{inspect(reason)}")
false
end)
|> Enum.map(fn {:ok, skill} -> skill end)
else
Logger.warning("Skills directory not found: #{path}")
[]
end
end
defp symlink_or_oversized?(path) do
case File.lstat(path) do
{:ok, %{type: :symlink}} ->
Logger.debug("Skipping symlink in skills directory: #{path}")
true
{:ok, %{size: size}} when size > @max_skill_file_bytes ->
Logger.warning(
"Skipping oversized skill file (#{size} bytes > #{@max_skill_file_bytes}): #{path}"
)
true
_ ->
false
end
end
@doc """
Load a single skill from a markdown file.
Parses YAML frontmatter for metadata. The markdown body is stored
but marked as `status: :discovered` for lazy loading.
"""
@spec load_file(String.t()) :: {:ok, Skill.t()} | {:error, term()}
def load_file(path) do
path = Path.expand(path)
case File.read(path) do
{:ok, content} ->
parse_skill(content, path)
{:error, reason} ->
{:error, {:read_failed, path, reason}}
end
end
@doc """
Parse a skill from raw markdown content with YAML frontmatter.
"""
@spec parse_skill(String.t(), String.t() | nil) :: {:ok, Skill.t()} | {:error, term()}
def parse_skill(content, source_path \\ nil) do
case parse_frontmatter(content) do
{:ok, metadata, body} ->
skill = %Skill{
name: Map.get(metadata, "name", path_to_name(source_path)),
description: Map.get(metadata, "description", ""),
tags: parse_tags(Map.get(metadata, "tags", [])),
group: parse_atom(Map.get(metadata, "group")),
instructions: String.trim(body),
activation: parse_activation(Map.get(metadata, "activation", "manual")),
scope: :project,
source: :file,
source_ref: source_path,
model_override: Map.get(metadata, "model_override"),
allowed_tools: parse_string_list(Map.get(metadata, "allowed_tools")),
priority: Map.get(metadata, "priority", 100),
status: :loaded
}
{:ok, skill}
{:error, _} = error ->
error
end
end
@doc """
Parse YAML frontmatter from markdown content.
Returns `{:ok, metadata_map, body}` or `{:error, reason}`.
"""
@spec parse_frontmatter(String.t()) :: {:ok, map(), String.t()} | {:error, term()}
def parse_frontmatter(content) do
# L-2: only treat the leading `---\n...\n---` as frontmatter; if the
# file doesn't START with `---` then it has no frontmatter (markdown
# horizontal rules using `---` later in the body shouldn't get treated
# as a frontmatter delimiter).
if String.starts_with?(content, "---\n") or String.starts_with?(content, "---\r\n") do
do_parse_frontmatter(content)
else
{:ok, %{}, content}
end
end
defp do_parse_frontmatter(content) do
case String.split(content, ~r/^---\s*$/m, parts: 3) do
[_, yaml, body] ->
case YamlElixir.read_from_string(yaml) do
{:ok, metadata} when is_map(metadata) ->
{:ok, metadata, body}
{:ok, _} ->
{:error, :invalid_frontmatter}
{:error, reason} ->
{:error, {:yaml_parse_error, reason}}
end
_ ->
# No frontmatter — treat entire content as instructions
{:ok, %{}, content}
end
end
# Convert file path to skill name (e.g., "priv/skills/code_review.md" -> "code_review")
defp path_to_name(nil), do: "unnamed"
defp path_to_name(path) do
path
|> Path.basename()
|> Path.rootname()
end
defp parse_tags(tags) when is_list(tags) do
tags |> Enum.map(&parse_atom/1) |> Enum.reject(&is_nil/1)
end
defp parse_tags(_), do: []
# Skill files come from disk and may be authored by anyone. NEVER call
# String.to_atom/1 on their frontmatter - atoms are not GC'd and the table
# is bounded; one malicious or generated skill file can exhaust it and
# crash the BEAM. Unknown tags are dropped (with a debug log).
defp parse_atom(nil), do: nil
defp parse_atom(val) when is_atom(val), do: val
defp parse_atom(val) when is_binary(val) do
String.to_existing_atom(val)
rescue
ArgumentError ->
Logger.debug("Skipping unknown skill tag: #{val}")
nil
end
defp parse_atom(_), do: nil
defp parse_activation("auto"), do: :auto
defp parse_activation("manual"), do: :manual
defp parse_activation(:auto), do: :auto
defp parse_activation(:manual), do: :manual
defp parse_activation(_), do: :manual
defp parse_string_list(nil), do: nil
defp parse_string_list(list) when is_list(list), do: Enum.map(list, &to_string/1)
defp parse_string_list(_), do: nil
end