Skip to main content

lib/coding_agent/skill.ex

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