defmodule CodingAgent.Skill do
@moduledoc """
A single skill discovered on disk, in the same shape Claude Code uses:
my_skill/
SKILL.md # YAML frontmatter (name, description, ...) + Markdown body
The frontmatter `description` is what gets shown to the model so it can
decide *when* to invoke the skill; the body is only loaded into the
conversation once the skill is actually invoked.
"""
@enforce_keys [:name, :description, :path, :body]
defstruct [:name, :description, :path, :body, allowed_tools: nil, metadata: %{}]
@type t :: %__MODULE__{
name: String.t(),
description: String.t(),
path: String.t(),
body: String.t(),
allowed_tools: [String.t()] | nil,
metadata: map()
}
@doc """
Loads a skill from a `SKILL.md` file path.
Returns `{:ok, skill}` or `{:error, reason}` if the file is missing or has
no usable frontmatter.
"""
@spec load(String.t()) :: {:ok, t()} | {:error, term()}
def load(skill_md_path) do
with {:ok, contents} <- File.read(skill_md_path),
{:ok, frontmatter, body} <- split_frontmatter(contents),
{:ok, name} <- fetch(frontmatter, "name", skill_md_path),
{:ok, description} <- fetch(frontmatter, "description", skill_md_path) do
allowed_tools =
case Map.get(frontmatter, "allowed-tools") do
nil -> nil
str -> str |> String.split(",") |> Enum.map(&String.trim/1)
end
metadata = Map.drop(frontmatter, ["name", "description", "allowed-tools"])
{:ok,
%__MODULE__{
name: name,
description: description,
path: skill_md_path,
body: String.trim(body),
allowed_tools: allowed_tools,
metadata: metadata
}}
end
end
defp fetch(frontmatter, key, path) do
case Map.fetch(frontmatter, key) do
{:ok, value} -> {:ok, value}
:error -> {:error, {:missing_frontmatter_field, key, path}}
end
end
defp split_frontmatter("---\n" <> rest) do
case String.split(rest, ~r/\n---\n?/, parts: 2) do
[yaml, body] -> {:ok, parse_flat_yaml(yaml), body}
_ -> {:error, :unterminated_frontmatter}
end
end
defp split_frontmatter(_), do: {:error, :no_frontmatter}
# Skill frontmatter in practice is a flat `key: value` map. Rather than
# pull in a YAML dependency for that, parse just enough of it directly.
defp parse_flat_yaml(yaml) do
yaml
|> String.split("\n")
|> Enum.reduce(%{}, fn line, acc ->
case String.split(line, ":", parts: 2) do
[key, value] ->
key = String.trim(key)
value = value |> String.trim() |> unquote_value()
if key == "", do: acc, else: Map.put(acc, key, value)
_ ->
acc
end
end)
end
defp unquote_value(<<?", rest::binary>>) do
String.trim_trailing(rest, "\"")
end
defp unquote_value(<<?', rest::binary>>) do
String.trim_trailing(rest, "'")
end
defp unquote_value(value), do: value
@doc """
Renders the system-prompt block listing available skills, in the same
spirit as Claude Code's own "available skills" reminder: name + description
only, so the model can decide which one (if any) is relevant.
"""
@spec catalog([t()]) :: String.t()
def catalog(skills) do
skills
|> Enum.map(fn s -> "- #{s.name}: #{s.description}" end)
|> Enum.join("\n")
end
end