Current section

Files

Jump to
codex_sdk lib codex plugins paths.ex
Raw

lib/codex/plugins/paths.ex

defmodule Codex.Plugins.Paths do
@moduledoc """
Canonical local authoring paths for plugin manifests and marketplaces.
"""
alias Codex.Plugins.Errors
@marketplace_relative_path Path.join([".agents", "plugins", "marketplace.json"])
@alternate_marketplace_relative_path Path.join([".claude-plugin", "marketplace.json"])
@manifest_relative_path Path.join([".codex-plugin", "plugin.json"])
@alternate_manifest_relative_path Path.join([".claude-plugin", "plugin.json"])
@type scope :: :repo | :personal
@doc """
Resolves the root directory for a repo or personal plugin scope.
"""
@spec scope_root(scope(), keyword()) :: {:ok, Path.t()} | {:error, term()}
def scope_root(scope, opts \\ [])
def scope_root(:repo, opts) when is_list(opts) do
cwd = Keyword.get(opts, :repo_root, Keyword.get(opts, :cwd, File.cwd!()))
find_repo_root(cwd)
end
def scope_root(:personal, opts) when is_list(opts) do
home =
Keyword.get(opts, :home) ||
Codex.Env.get("HOME") ||
Codex.Env.get("USERPROFILE") ||
System.user_home!()
{:ok, Path.expand(home)}
end
def scope_root(scope, _opts), do: {:error, Errors.invalid_scope(scope)}
@doc """
Resolves the canonical plugin root for a scope and plugin name.
"""
@spec plugin_root(scope(), String.t(), keyword()) :: {:ok, Path.t()} | {:error, term()}
def plugin_root(scope, plugin_name, opts) when is_binary(plugin_name) and is_list(opts) do
case Keyword.get(opts, :root, Keyword.get(opts, :plugin_root)) do
nil ->
with {:ok, root} <- scope_root(scope, opts) do
{:ok, Path.join(root, Path.join("plugins", plugin_name))}
end
root ->
{:ok, Path.expand(root)}
end
end
@doc """
Resolves the canonical marketplace path for a scope.
"""
@spec marketplace_path(scope(), keyword()) :: {:ok, Path.t()} | {:error, term()}
def marketplace_path(scope, opts) when is_list(opts) do
case Keyword.get(opts, :marketplace_path) do
path when is_binary(path) ->
{:ok, Path.expand(path)}
_ ->
with {:ok, root} <- scope_root(scope, opts) do
{:ok, Path.join(root, @marketplace_relative_path)}
end
end
end
@doc """
Resolves the canonical manifest path for a plugin root or manifest path.
"""
@spec manifest_path(Path.t()) :: Path.t()
def manifest_path(path) when is_binary(path) do
expanded = Path.expand(path)
cond do
discoverable_manifest_path?(expanded) ->
expanded
File.exists?(Path.join(expanded, @manifest_relative_path)) ->
Path.join(expanded, @manifest_relative_path)
File.exists?(Path.join(expanded, @alternate_manifest_relative_path)) ->
Path.join(expanded, @alternate_manifest_relative_path)
true ->
Path.join(expanded, @manifest_relative_path)
end
end
@doc """
Returns the root directory that owns a canonical marketplace file.
"""
@spec marketplace_root(Path.t()) :: {:ok, Path.t()} | {:error, term()}
def marketplace_root(path) when is_binary(path) do
expanded = Path.expand(path)
plugins_dir = Path.dirname(expanded)
dot_agents_dir = Path.dirname(plugins_dir)
root_dir = Path.dirname(dot_agents_dir)
cond do
Path.basename(expanded) != "marketplace.json" ->
{:error,
Errors.invalid_marketplace_path(
expanded,
invalid_marketplace_path_message()
)}
Path.basename(plugins_dir) == "plugins" and Path.basename(dot_agents_dir) == ".agents" ->
{:ok, root_dir}
Path.basename(plugins_dir) == ".claude-plugin" ->
{:ok, dot_agents_dir}
true ->
{:error,
Errors.invalid_marketplace_path(
expanded,
invalid_marketplace_path_message()
)}
end
end
@doc """
Validates a manifest or marketplace relative path.
"""
@spec normalize_relative_path(term()) :: {:ok, String.t()} | {:error, String.t()}
def normalize_relative_path(value) when is_binary(value) do
path = String.trim(value)
cond do
path == "" ->
{:error, "path must not be empty"}
not String.starts_with?(path, "./") ->
{:error, "path must start with `./`"}
true ->
relative = String.replace_prefix(path, "./", "")
cond do
relative == "" ->
{:error, "path must not be `./`"}
Path.type(relative) != :relative ->
{:error, "path must stay within the plugin root"}
invalid_component?(relative) ->
{:error, "path must stay within the plugin root"}
true ->
{:ok, path}
end
end
end
def normalize_relative_path(_value), do: {:error, "expected a relative path string"}
@doc """
Validates a marketplace source path before resolving it against the marketplace root.
"""
@spec normalize_marketplace_source_path(term()) :: {:ok, String.t()} | {:error, String.t()}
def normalize_marketplace_source_path(value) when is_binary(value) do
path = String.trim(value)
cond do
path == "" ->
{:error, "path must not be empty"}
not String.starts_with?(path, "./") ->
{:error, "path must start with `./`"}
true ->
relative = String.replace_prefix(path, "./", "")
cond do
relative == "" ->
{:error, "path must not be `./`"}
Path.type(relative) != :relative ->
{:error, "path must resolve relative to the marketplace root"}
invalid_empty_component?(relative) ->
{:error, "path must resolve relative to the marketplace root"}
true ->
{:ok, path}
end
end
end
def normalize_marketplace_source_path(_value),
do: {:error, "expected a relative path string"}
@doc """
Resolves a plugin root into a marketplace-relative `./...` source path.
"""
@spec relative_plugin_source_path(Path.t(), Path.t()) :: {:ok, String.t()} | {:error, term()}
def relative_plugin_source_path(marketplace_path, plugin_root)
when is_binary(marketplace_path) and is_binary(plugin_root) do
with {:ok, root} <- marketplace_root(marketplace_path) do
expanded_root = Path.expand(root)
expanded_plugin_root = Path.expand(plugin_root)
relative = Path.relative_to(expanded_plugin_root, expanded_root)
cond do
relative in [".", ""] ->
{:error,
Errors.invalid_plugin_root(
plugin_root,
"plugin root must not equal the marketplace root"
)}
Path.type(relative) == :absolute ->
{:error,
Errors.invalid_plugin_root(
plugin_root,
"plugin root must stay inside the marketplace root"
)}
String.starts_with?(relative, "../") or relative == ".." ->
{:error,
Errors.invalid_plugin_root(
plugin_root,
"plugin root must stay inside the marketplace root"
)}
true ->
{:ok, "./" <> relative}
end
end
end
@doc """
Resolves a marketplace entry source path to an absolute path under its root.
"""
@spec resolve_marketplace_source_path(String.t(), String.t()) ::
{:ok, String.t()} | {:error, term()}
def resolve_marketplace_source_path(marketplace_path, source_path)
when is_binary(marketplace_path) and is_binary(source_path) do
with {:ok, root} <- marketplace_root(marketplace_path),
{:ok, normalized} <- normalize_marketplace_source_path(source_path) do
relative = String.replace_prefix(normalized, "./", "")
expanded_root = Path.expand(root)
case resolve_relative_marketplace_source(relative, expanded_root) do
{:ok, resolved_path} ->
{:ok, resolved_path}
{:error, message} ->
{:error, Errors.invalid_marketplace_source_path(marketplace_path, source_path, message)}
end
else
{:error, message} when is_binary(message) ->
{:error, Errors.invalid_marketplace_source_path(marketplace_path, source_path, message)}
{:error, reason} ->
{:error, reason}
end
end
defp find_repo_root(path) when is_binary(path) do
expanded =
path
|> Path.expand()
|> then(fn candidate ->
if File.dir?(candidate), do: candidate, else: Path.dirname(candidate)
end)
do_find_repo_root(expanded)
end
defp do_find_repo_root(path) do
cond do
File.exists?(Path.join(path, ".git")) ->
{:ok, path}
Path.dirname(path) == path ->
{:error, Errors.repo_root_not_found(path)}
true ->
do_find_repo_root(Path.dirname(path))
end
end
defp invalid_component?(relative_path) do
relative_path
|> Path.split()
|> Enum.any?(&(&1 in [".", "..", ""]))
end
defp invalid_empty_component?(relative_path) do
relative_path
|> Path.split()
|> Enum.any?(&(&1 in [".", ""]))
end
defp resolve_relative_marketplace_source(relative, expanded_root) do
with :ok <- ensure_relative_marketplace_components(relative) do
resolved_path = Path.expand(relative, expanded_root)
relative_to_root = Path.relative_to(resolved_path, expanded_root)
if Path.type(relative_to_root) == :absolute or relative_to_root == ".." or
String.starts_with?(relative_to_root, "../") do
{:error, "path must stay within the marketplace root"}
else
{:ok, resolved_path}
end
end
end
defp ensure_relative_marketplace_components(relative) do
if invalid_component?(relative) do
{:error, "path must stay within the marketplace root"}
else
:ok
end
end
defp discoverable_manifest_path?(path) do
Path.basename(path) == "plugin.json" and
Path.basename(Path.dirname(path)) in [".codex-plugin", ".claude-plugin"]
end
defp invalid_marketplace_path_message do
"marketplace file must live under `<root>/#{@marketplace_relative_path}` or `<root>/#{@alternate_marketplace_relative_path}`"
end
end