Packages
fnord
0.9.38
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/ai/tools/save_skill.ex
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/<project>/skills/<name>.toml`
global scope: `~/.fnord/skills/<name>.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/<project>/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