defmodule AI.Tools.SaveSkill do @moduledoc """ Save a new skill definition either into the current project's skills directory or into the user-global skills directory. The coordinator can use this tool to persist skill TOML files under: project scope: `~/.fnord/projects//skills/.toml` global scope: `~/.fnord/skills/.toml` Safety rules: - The tool is synchronous. - It requires explicit user confirmation via `UI.confirm/2`. - For project scope, it refuses to create a project skill if a user-defined skill with the same name already exists in `~/fnord/skills` (because user definitions override project definitions). - For global scope, it writes directly to the user skills directory (overwriting existing entries if any). """ @behaviour AI.Tools @tool_name "save_skill" @impl AI.Tools def async?, do: false @impl AI.Tools def is_available?, do: true @impl AI.Tools def ui_note_on_request(%{"name" => name}) do {"Save skill", name} end @impl AI.Tools def ui_note_on_result(%{"name" => name}, _result) do {"Saved skill", name} end @impl AI.Tools def tool_call_failure_message(_args, _reason), do: :default @impl AI.Tools def read_args(%{} = args) do with {:ok, name} <- get_string(args, "name"), {:ok, description} <- get_string(args, "description"), {:ok, model} <- get_string(args, "model"), {:ok, tools} <- get_string_list(args, "tools"), {:ok, system_prompt} <- get_string(args, "system_prompt"), {:ok, response_format} <- get_optional_map(args, "response_format"), {:ok, scope} <- get_scope(args) do {:ok, %{ "name" => name, "description" => description, "model" => model, "tools" => tools, "system_prompt" => system_prompt, "response_format" => response_format, "scope" => scope }} end end defp get_string(args, key) do case Map.fetch(args, key) do {:ok, value} when is_binary(value) and byte_size(value) > 0 -> {:ok, value} _ -> {:error, "Missing or empty required argument: #{key}"} end end defp get_string_list(args, key) do case Map.fetch(args, key) do {:ok, list} when is_list(list) -> list |> Enum.filter(&is_binary/1) |> case do [] -> {:error, "Missing or empty required argument: #{key}"} strings -> {:ok, strings} end _ -> {:error, "Missing or empty required argument: #{key}"} end end defp get_optional_map(args, key) do case Map.fetch(args, key) do :error -> {:ok, nil} {:ok, nil} -> {:ok, nil} {:ok, value} when is_map(value) -> {:ok, value} _ -> {:error, {:invalid_arg, key}} end end @spec get_scope(map) :: {:ok, String.t()} | {:error, String.t()} defp get_scope(args) do case Map.fetch(args, "scope") do :error -> {:ok, "project"} {:ok, val} when val in ["project", "global"] -> {:ok, val} {:ok, invalid} -> {:error, "Invalid scope '#{invalid}'. Allowed values are \"project\" or \"global\"."} end end @impl AI.Tools def spec() do %{ type: "function", name: @tool_name, description: "Save a skill into either the current project's skills directory (~/.fnord/projects//skills) or the user-global skills directory (~/.fnord/skills).", parameters: %{ type: "object", additionalProperties: false, properties: %{ name: %{type: "string", description: "Skill name"}, description: %{type: "string", description: "Brief description"}, model: %{ type: "string", description: "Model preset (smart/balanced/fast/web/large_context...)" }, tools: %{type: "array", items: %{type: "string"}, description: "Tool tags"}, system_prompt: %{type: "string", description: "Base system prompt"}, response_format: %{ anyOf: [%{type: "object"}, %{type: "null"}], description: "Optional response_format map" }, scope: %{ type: "string", description: "Scope of the skill: \"project\" or \"global\" (default \"project\")" } }, required: ["name", "description", "model", "tools", "system_prompt"] } } end @valid_name_pattern ~r/^[a-z0-9][a-z0-9_-]*$/ @spec resolve_target_dir(binary, binary) :: {:ok, binary} | {:error, any} defp resolve_target_dir("project", name) do case Skills.project_skills_dir() do {:ok, dir} -> case check_user_collision(name) do :ok -> {:ok, dir} {:error, reason} -> {:error, reason} end {:error, :no_project_selected} -> {:error, "No project selected. Select a project or set scope=\"global\"."} end end defp resolve_target_dir("global", _name) do {:ok, Skills.user_skills_dir()} end @impl AI.Tools def call(%{ "name" => name, "description" => description, "model" => model, "tools" => tools, "system_prompt" => system_prompt, "response_format" => response_format, "scope" => scope }) do with :ok <- validate_name(name), {:ok, target_dir} <- resolve_target_dir(scope, name), {:ok, _model_preset} <- Skills.Runtime.model_from_string(model), {:ok, _toolbox} <- Skills.Runtime.toolbox_from_tags(tools), {:ok, _rf} <- Skills.Runtime.validate_response_format(response_format), {:ok, toml} <- Skills.Toml.encode_skill(%Skills.Skill{ name: name, description: description, model: model, tools: tools, system_prompt: system_prompt, response_format: response_format }), {:ok, path} <- skill_path(target_dir, name), :ok <- confirm_write(path), :ok <- write_skill_file(path, toml) do {:ok, "Saved skill #{name} to #{path}"} end end defp validate_name(name) do case Regex.match?(@valid_name_pattern, name) do true -> :ok false -> {:error, "Invalid skill name '#{name}'. Names must match [a-z0-9][a-z0-9_-]*."} end end defp check_user_collision(name) do user_dir = Skills.user_skills_dir() # Check only whether a file with this name exists in the user dir, rather # than loading the entire directory (which can fail on unrelated issues # like duplicate names among other skills). path = Path.join(user_dir, "#{name}.toml") if File.exists?(path) do {:error, "A user-defined skill named '#{name}' already exists at #{path}. " <> "User skills override project skills, so this save would be shadowed."} else :ok end end defp skill_path(project_dir, name) do File.mkdir_p(project_dir) |> case do :ok -> {:ok, Path.join(project_dir, "#{name}.toml")} {:error, reason} -> {:error, reason} end end defp confirm_write(path) do case UI.confirm("Write skill to #{path}?", false) do true -> :ok false -> {:error, :aborted} end end defp write_skill_file(path, toml) do lock_path = path <> ".lock" FileLock.with_lock(lock_path, fn -> Settings.write_atomic!(path, toml) :ok end) |> case do {:ok, :ok} -> :ok {:error, reason} -> {:error, reason} end rescue e -> {:error, Exception.message(e)} end end