defmodule Pi.Skill.Script do @moduledoc "Behaviour and DSL for trusted executable `.skill.exs` files." alias Pi.Plugin.API @type metadata :: %{ required(:name) => String.t(), optional(:version) => String.t(), optional(:description) => String.t(), optional(:triggers) => [String.t()], optional(:alias) => atom(), optional(:examples) => [String.t()] } @callback metadata() :: metadata() @callback markdown() :: String.t() @callback apis() :: [API.t() | keyword() | map()] @callback prompt_context(map()) :: String.t() @callback validate(map()) :: :ok | {:error, term()} @optional_callbacks apis: 0, prompt_context: 1, validate: 1 defmacro __using__(_opts) do quote do @behaviour Pi.Skill.Script import Pi.Skill.Script, only: [skill: 1] Module.register_attribute(__MODULE__, :pi_skill, accumulate: false) @before_compile Pi.Skill.Script end end defmacro skill(do: block) do metadata = parse_skill_block(block, __CALLER__) quote do @pi_skill unquote(Macro.escape(metadata)) end end defmacro __before_compile__(env) do metadata = Module.get_attribute(env.module, :pi_skill) || %{} markdown = Map.get(metadata, :markdown) || moduledoc_markdown(env.module) metadata = Map.delete(metadata, :markdown) api = default_api(metadata, env.module) quote do def metadata, do: unquote(Macro.escape(metadata)) def markdown, do: unquote(markdown) def apis, do: unquote(Macro.escape(api)) def prompt_context(_context), do: markdown() def validate(_context), do: :ok defoverridable metadata: 0, markdown: 0, apis: 0, prompt_context: 1, validate: 1 end end defp parse_skill_block({:__block__, _meta, expressions}, env) do expressions |> Enum.map(&parse_expression(&1, env)) |> Map.new() end defp parse_skill_block(expression, env), do: Map.new([parse_expression(expression, env)]) defp parse_expression({name, _meta, [value]}, env) when name in [:name, :version, :description, :triggers, :examples, :markdown] do {name, Macro.expand(value, env)} end defp parse_expression({:alias_as, _meta, [value]}, env) do {:alias, value |> Macro.expand(env) |> module_alias()} end defp parse_expression(expression, _env) do raise ArgumentError, "unsupported skill DSL expression: #{Macro.to_string(expression)}" end defp module_alias(module) when is_atom(module) do module |> Module.split() |> List.last() |> :erlang.binary_to_atom() end defp moduledoc_markdown(module) do case Module.get_attribute(module, :moduledoc) do {_line, markdown} when is_binary(markdown) -> markdown markdown when is_binary(markdown) -> markdown _other -> "" end end defp default_api(%{name: name, alias: alias_name} = metadata, module) when is_binary(name) and is_atom(alias_name) do [ API.new( name: name |> String.replace("-", "_") |> :erlang.binary_to_atom(), module: module, alias: alias_name, description: Map.get(metadata, :description, ""), examples: Map.get(metadata, :examples, []) ) ] end defp default_api(_metadata, _module), do: [] end