defmodule Pi.Skill.Loader do @moduledoc "Discovers trusted executable Elixir skills in the current Mix project." alias Pi.Plugin.API alias Pi.Project.Context alias Pi.Protocol.API.Extension alias Pi.Protocol.SkillInfo alias Pi.Skill.Executable @spec load_file(String.t()) :: {:ok, [Executable.t()]} | {:error, term()} def load_file(path) when is_binary(path) do path = Path.expand(path) key = {__MODULE__, :file, path} mtime = File.stat!(path).mtime case :persistent_term.get(key, nil) do {^mtime, skills} -> {:ok, skills} _stale -> modules = path |> Code.compile_file() |> Enum.map(&elem(&1, 0)) |> Enum.filter(&skill_module?/1) skills = Enum.map(modules, &executable(&1, path)) :persistent_term.put(key, {mtime, skills}) {:ok, skills} end rescue exception in [ArgumentError, Code.LoadError, CompileError, File.Error, SyntaxError] -> {:error, Exception.format(:error, exception, __STACKTRACE__)} end @spec discover(keyword()) :: [Executable.t()] def discover(opts \\ []) do opts |> Keyword.get(:paths, default_paths()) |> Enum.flat_map(fn dir -> case load_dir(dir) do {:ok, skills} -> skills {:error, _reason} -> [] end end) |> Enum.uniq_by(&{&1.name, &1.module}) end @spec serializable(keyword()) :: [SkillInfo.t()] def serializable(opts \\ []) do opts |> discover() |> Enum.map(fn skill -> %SkillInfo{ name: skill.name, path: skill.path, module: skill.module, metadata: atom_keys_to_strings(skill.metadata), markdown: skill.markdown, apis: Enum.map(skill.apis, &Extension.from_api/1) } end) end defp load_dir(dir) do dir |> files() |> Enum.reduce_while({:ok, []}, fn file, {:ok, acc} -> case load_file(file) do {:ok, skills} -> {:cont, {:ok, [skills | acc]}} {:error, reason} -> {:halt, {:error, {file, reason}}} end end) |> case do {:ok, skills} -> {:ok, skills |> Enum.reverse() |> List.flatten()} error -> error end end defp default_paths do root = Context.current().root [ Path.join(root, "priv/skills"), Path.join(root, ".pi/skills"), Path.join(root, "skills") ] ++ dependency_skill_paths() end defp dependency_skill_paths do (loaded_app_skill_paths() ++ mix_dependency_skill_paths()) |> Enum.uniq() end defp loaded_app_skill_paths do Application.loaded_applications() |> Enum.flat_map(fn {app, _description, _version} -> app_skill_path(app) end) end defp mix_dependency_skill_paths do if Code.ensure_loaded?(Mix.Dep) and Mix.Project.get() do Mix.Dep.cached() |> Enum.reject(&loaded_app?/1) |> Enum.flat_map(fn dep -> [dep.opts[:build], dep.opts[:dest]] |> Enum.reject(&is_nil/1) |> Enum.map(&Path.join(&1, "priv/skills")) end) else [] end rescue _exception in [Mix.Error, ArgumentError] -> [] end defp loaded_app?(%Mix.Dep{app: app}) when is_atom(app) do match?(path when is_list(path), :code.priv_dir(app)) end defp loaded_app?(_dep), do: false defp app_skill_path(app) do case :code.priv_dir(app) do priv_dir when is_list(priv_dir) -> [Path.join([List.to_string(priv_dir), "skills"])] {:error, :bad_name} -> [] end end defp files(dir) do dir = Path.expand(dir) [ Path.join(dir, "**/*.skill.exs"), Path.join(dir, "**/skill.exs") ] |> Enum.flat_map(&Path.wildcard/1) |> Enum.uniq() end defp skill_module?(module) do Code.ensure_loaded?(module) and Pi.Skill.Script in behaviours(module) end defp behaviours(module), do: module.module_info(:attributes) |> Keyword.get(:behaviour, []) defp executable(module, path) do metadata = module.metadata() name = Map.fetch!(metadata, :name) %Executable{ name: name, path: path, module: module, metadata: metadata, markdown: module.markdown(), apis: normalize_apis(module.apis()) } end defp normalize_apis(apis) do apis = List.wrap(apis) case apis do [{key, _value} | _rest] when is_atom(key) -> [API.new(apis)] apis -> Enum.map(apis, &API.new/1) end end defp atom_keys_to_strings(map) do Map.new(map, fn {key, value} when is_atom(key) -> {Atom.to_string(key), value} entry -> entry end) end end