Current section
Files
Jump to
Current section
Files
lib/codex/plugins/marketplace.ex
defmodule Codex.Plugins.Marketplace do
@moduledoc """
Local authoring model for `.agents/plugins/marketplace.json`.
"""
use TypedStruct
alias CliSubprocessCore.Schema.Conventions
alias Codex.Plugins.Paths
alias Codex.Protocol.Plugin.{AuthPolicy, InstallPolicy}
alias Codex.Schema
@key_mapping %{"display_name" => "displayName"}
@known_fields ["name", "interface", "plugins"]
@interface_known_fields ["displayName"]
@plugin_known_fields ["name", "source", "policy", "category"]
@source_known_fields ["source", "path"]
@policy_known_fields ["installation", "authentication", "products"]
@type source_t :: %{
source: :local,
path: String.t(),
extra: map()
}
@type policy_t :: %{
installation: InstallPolicy.t(),
authentication: AuthPolicy.t(),
products: [String.t()] | nil,
extra: map()
}
@type plugin_t :: %{
name: String.t(),
source: source_t(),
policy: policy_t(),
category: String.t(),
extra: map()
}
@type interface_t :: %{
display_name: String.t() | nil,
extra: map()
}
typedstruct do
field(:name, String.t(), enforce: true)
field(:interface, interface_t() | nil)
field(:plugins, [plugin_t()], default: [])
field(:extra, map(), default: %{})
end
@doc """
Returns the schema used to validate marketplace data.
"""
@spec schema() :: Zoi.schema()
def schema do
Zoi.map(
%{
"name" => Zoi.any() |> Zoi.transform({__MODULE__, :normalize_name, []}),
"interface" => optional_interface_schema(),
"plugins" => Zoi.array(plugin_schema())
},
unrecognized_keys: :preserve
)
end
@doc """
Parses marketplace data into a `%Codex.Plugins.Marketplace{}` struct.
"""
@spec parse(map() | keyword() | t()) ::
{:ok, t()}
| {:error, {:invalid_plugin_marketplace, CliSubprocessCore.Schema.error_detail()}}
def parse(%__MODULE__{} = value), do: parse(to_map(value))
def parse(data) do
data
|> Schema.normalize_input(@key_mapping)
|> then(&Schema.parse(schema(), &1, :invalid_plugin_marketplace))
|> project()
end
@doc """
Parses a single marketplace entry for add/update flows.
"""
@spec parse_plugin(map() | keyword() | plugin_t()) ::
{:ok, plugin_t()}
| {:error,
{:invalid_plugin_marketplace_plugin, CliSubprocessCore.Schema.error_detail()}}
def parse_plugin(%{} = plugin)
when is_map_key(plugin, :source) and is_map_key(plugin, :policy) and
is_map_key(plugin, :name) do
parse_plugin(to_plugin_map(plugin))
end
def parse_plugin(data) do
data
|> Schema.normalize_input(@key_mapping)
|> then(&Schema.parse(plugin_schema(), &1, :invalid_plugin_marketplace_plugin))
|> case do
{:ok, parsed} -> {:ok, build_plugin(parsed)}
{:error, _reason} = error -> error
end
end
@doc """
Parses marketplace data and raises on invalid input.
"""
@spec parse!(map() | keyword() | t()) :: t()
def parse!(data) do
case parse(data) do
{:ok, marketplace} -> marketplace
{:error, {tag, details}} -> raise CliSubprocessCore.Schema.Error, tag: tag, details: details
end
end
@doc """
Compatibility alias for `parse!/1`.
"""
@spec from_map(map() | keyword() | t()) :: t()
def from_map(data), do: parse!(data)
@doc """
Serializes a marketplace struct back into canonical JSON-compatible data.
"""
@spec to_map(t()) :: map()
def to_map(%__MODULE__{} = value) do
%{}
|> Schema.put_present("name", value.name)
|> Schema.put_present("interface", encode_interface(value.interface))
|> Map.put("plugins", Enum.map(value.plugins, &encode_plugin/1))
|> Schema.merge_extra(value.extra)
end
@doc false
@spec put_plugin(t(), plugin_t(), keyword()) :: {:ok, t()} | {:error, term()}
def put_plugin(%__MODULE__{} = marketplace, plugin, opts \\ []) do
overwrite? = Keyword.get(opts, :overwrite, false)
case Enum.find_index(marketplace.plugins, &(&1.name == plugin.name)) do
nil ->
{:ok, %{marketplace | plugins: marketplace.plugins ++ [plugin]}}
index when overwrite? ->
existing_plugin = Enum.at(marketplace.plugins, index)
merged_plugin = merge_plugin(existing_plugin, plugin)
{:ok,
%{marketplace | plugins: List.replace_at(marketplace.plugins, index, merged_plugin)}}
_index ->
{:error, {:plugin_conflict, %{plugin_name: plugin.name}}}
end
end
@doc false
@spec merge(t(), t(), keyword()) :: {:ok, t()} | {:error, term()}
def merge(%__MODULE__{} = existing, %__MODULE__{} = incoming, opts \\ []) do
overwrite? = Keyword.get(opts, :overwrite, false)
with {:ok, plugins} <- merge_plugins(existing.plugins, incoming.plugins, overwrite?) do
{:ok,
%__MODULE__{
name: existing.name || incoming.name,
interface: merge_interface(existing.interface, incoming.interface),
plugins: plugins,
extra: Map.merge(existing.extra, incoming.extra)
}}
end
end
@doc false
@spec normalize_name(term(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
def normalize_name(value, _opts) when is_binary(value) do
name = String.trim(value)
if name == "" do
{:error, "expected a non-empty marketplace name"}
else
{:ok, name}
end
end
def normalize_name(_value, _opts), do: {:error, "expected a marketplace name string"}
@doc false
@spec normalize_source_type(term(), keyword()) :: {:ok, :local} | {:error, String.t()}
def normalize_source_type(:local, _opts), do: {:ok, :local}
def normalize_source_type("local", _opts), do: {:ok, :local}
def normalize_source_type(_value, _opts), do: {:error, "expected `local` as the plugin source"}
@doc false
@spec normalize_relative_path(term(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
def normalize_relative_path(value, _opts), do: Paths.normalize_marketplace_source_path(value)
defp project({:ok, parsed}), do: {:ok, build(parsed)}
defp project({:error, _reason} = error), do: error
defp build(parsed) do
{known, extra} = Schema.split_extra(parsed, @known_fields)
%__MODULE__{
name: Map.fetch!(known, "name"),
interface: build_interface(Map.get(known, "interface")),
plugins: Enum.map(Map.get(known, "plugins", []), &build_plugin/1),
extra: extra
}
end
defp build_interface(nil), do: nil
defp build_interface(%{} = interface) do
{known, extra} = Schema.split_extra(interface, @interface_known_fields)
interface_map = %{display_name: Map.get(known, "displayName"), extra: extra}
if interface_map.display_name == nil and extra == %{}, do: nil, else: interface_map
end
defp build_plugin(parsed) do
{known, extra} = Schema.split_extra(parsed, @plugin_known_fields)
%{
name: Map.fetch!(known, "name"),
source: build_source(Map.fetch!(known, "source")),
policy: build_policy(Map.fetch!(known, "policy")),
category: Map.fetch!(known, "category"),
extra: extra
}
end
defp build_source(parsed) do
{known, extra} = Schema.split_extra(parsed, @source_known_fields)
%{
source: Map.fetch!(known, "source"),
path: Map.fetch!(known, "path"),
extra: extra
}
end
defp build_policy(parsed) do
{known, extra} = Schema.split_extra(parsed, @policy_known_fields)
%{
installation: Map.fetch!(known, "installation"),
authentication: Map.fetch!(known, "authentication"),
products: Map.get(known, "products"),
extra: extra
}
end
defp encode_interface(nil), do: nil
defp encode_interface(interface) do
%{}
|> Schema.put_present("displayName", interface[:display_name])
|> Schema.merge_extra(interface[:extra] || %{})
end
defp encode_plugin(plugin) do
%{}
|> Schema.put_present("name", plugin[:name])
|> Schema.put_present("source", encode_source(plugin[:source]))
|> Schema.put_present("policy", encode_policy(plugin[:policy]))
|> Schema.put_present("category", plugin[:category])
|> Schema.merge_extra(plugin[:extra] || %{})
end
defp encode_source(source) do
%{}
|> Map.put("source", "local")
|> Schema.put_present("path", source[:path])
|> Schema.merge_extra(source[:extra] || %{})
end
defp encode_policy(policy) do
%{}
|> Map.put("installation", InstallPolicy.to_wire(policy[:installation]))
|> Map.put("authentication", AuthPolicy.to_wire(policy[:authentication]))
|> Schema.put_present("products", policy[:products])
|> Schema.merge_extra(policy[:extra] || %{})
end
defp optional_string do
Zoi.optional(Zoi.nullish(Conventions.trimmed_string()))
end
defp required_string do
Conventions.trimmed_string()
|> Zoi.min(1)
end
defp optional_string_list do
Zoi.optional(Zoi.nullish(Zoi.array(required_string())))
end
defp optional_interface_schema do
Zoi.optional(
Zoi.nullish(Zoi.map(%{"displayName" => optional_string()}, unrecognized_keys: :preserve))
)
end
defp plugin_schema do
Zoi.map(
%{
"name" =>
Zoi.any() |> Zoi.transform({Codex.Plugins.Manifest, :normalize_plugin_name, []}),
"source" => source_schema(),
"policy" => policy_schema(),
"category" => required_string()
},
unrecognized_keys: :preserve
)
end
defp source_schema do
Zoi.map(
%{
"source" => Zoi.any() |> Zoi.transform({__MODULE__, :normalize_source_type, []}),
"path" => Zoi.any() |> Zoi.transform({__MODULE__, :normalize_relative_path, []})
},
unrecognized_keys: :preserve
)
end
defp policy_schema do
Zoi.map(
%{
"installation" => InstallPolicy.schema(),
"authentication" => AuthPolicy.schema(),
"products" => optional_string_list()
},
unrecognized_keys: :preserve
)
end
defp merge_interface(nil, interface), do: interface
defp merge_interface(interface, nil), do: interface
defp merge_interface(existing, incoming) do
%{
display_name: existing[:display_name] || incoming[:display_name],
extra: Map.merge(existing[:extra] || %{}, incoming[:extra] || %{})
}
end
defp merge_plugins(existing_plugins, incoming_plugins, overwrite?) do
Enum.reduce_while(incoming_plugins, {:ok, existing_plugins}, fn plugin, {:ok, acc} ->
case put_plugin(%__MODULE__{name: "merged", plugins: acc}, plugin, overwrite: overwrite?) do
{:ok, %{plugins: plugins}} ->
{:cont, {:ok, plugins}}
{:error, {:plugin_conflict, %{plugin_name: plugin_name}}} ->
{:halt, {:error, {:plugin_conflict, %{plugin_name: plugin_name}}}}
end
end)
end
defp merge_plugin(nil, incoming), do: incoming
defp merge_plugin(existing, incoming) do
%{
name: incoming[:name] || existing[:name],
source: merge_source(existing[:source], incoming[:source]),
policy: merge_policy(existing[:policy], incoming[:policy]),
category: incoming[:category] || existing[:category],
extra: Map.merge(existing[:extra] || %{}, incoming[:extra] || %{})
}
end
defp merge_source(nil, source), do: source
defp merge_source(source, nil), do: source
defp merge_source(existing, incoming) do
%{
source: incoming[:source] || existing[:source],
path: incoming[:path] || existing[:path],
extra: Map.merge(existing[:extra] || %{}, incoming[:extra] || %{})
}
end
defp merge_policy(nil, policy), do: policy
defp merge_policy(policy, nil), do: policy
defp merge_policy(existing, incoming) do
%{
installation: incoming[:installation] || existing[:installation],
authentication: incoming[:authentication] || existing[:authentication],
products: incoming[:products] || existing[:products],
extra: Map.merge(existing[:extra] || %{}, incoming[:extra] || %{})
}
end
defp to_plugin_map(plugin) do
%{
"name" => plugin[:name] || plugin["name"],
"source" => plugin[:source] || plugin["source"],
"policy" => plugin[:policy] || plugin["policy"],
"category" => plugin[:category] || plugin["category"]
}
|> Schema.merge_extra(Map.get(plugin, :extra, Map.get(plugin, "extra", %{})))
end
end