Skip to content
2 changes: 1 addition & 1 deletion lib/claude_code/adapter/port/resolver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ defmodule ClaudeCode.Adapter.Port.Resolver do

defp find_global do
cond do
path = System.find_executable("claude") -> {:ok, path}
path = ClaudeCode.System.find_executable("claude") -> {:ok, path}
path = Installer.find_in_common_locations() -> {:ok, path}
true -> {:error, :not_found}
end
Expand Down
138 changes: 133 additions & 5 deletions lib/claude_code/cli/command.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ defmodule ClaudeCode.CLI.Command do

alias ClaudeCode.Session.PermissionMode

require Logger

@required_flags ["--output-format", "stream-json", "--verbose", "--print"]

@doc """
Expand Down Expand Up @@ -58,6 +60,8 @@ defmodule ClaudeCode.CLI.Command do
def to_cli_args(opts) do
opts
|> preprocess_sandbox()
|> preprocess_marketplaces()
|> preprocess_plugins()
|> preprocess_thinking()
|> ensure_setting_sources()
|> Enum.reduce([], fn {key, value}, acc ->
Expand Down Expand Up @@ -221,16 +225,21 @@ defmodule ClaudeCode.CLI.Command do
if value == [] do
nil
else
# Extract path from each plugin config (string path or map with type: :local)
# After preprocess_plugins, plugins are already normalized maps.
# Also handle non-preprocessed calls (direct to_cli_args).
Enum.flat_map(value, fn
path when is_binary(path) ->
["--plugin-dir", path]

%{type: :local, path: path} ->
["--plugin-dir", to_string(path)]

_other ->
%{type: :marketplace} ->
# Marketplace plugins are handled via settings injection, not CLI flags
[]

other ->
case normalize_plugin(other) do
%{type: :local, path: path} -> ["--plugin-dir", to_string(path)]
%{type: :marketplace} -> []
end
end)
end
end
Expand Down Expand Up @@ -355,6 +364,7 @@ defmodule ClaudeCode.CLI.Command do
# :prompt_suggestions and :tool_config are sent via control protocol initialize
# :can_use_tool is handled above (maps to --permission-prompt-tool stdio)
defp convert_option(:sandbox, _value), do: nil
defp convert_option(:marketplaces, _value), do: nil
defp convert_option(:thinking, _value), do: nil
defp convert_option(:enable_file_checkpointing, _value), do: nil
defp convert_option(:callers, _value), do: nil
Expand Down Expand Up @@ -434,6 +444,124 @@ defmodule ClaudeCode.CLI.Command do
end
end

# -- Private: shared settings injection helper --------------------------------

# Injects a key-value pair into settings. Returns {:ok, merged_settings} for
# nil and map settings, :skip for string settings (file paths).
defp inject_into_settings(nil, key, value) do
{:ok, %{key => value}}
end

defp inject_into_settings(settings, key, value) when is_map(settings) do
{:ok, Map.update(settings, key, value, &Map.merge(&1, value))}
end

defp inject_into_settings(_string_settings, _key, _value) do
:skip
end

# -- Private: marketplace preprocessing --------------------------------------

defp preprocess_marketplaces(opts) do
case Keyword.get(opts, :marketplaces) do
nil ->
opts

[] ->
opts

marketplaces ->
extra =
Map.new(marketplaces, fn
%{name: name} = m ->
{name, Map.delete(m, :name)}

other ->
raise ArgumentError,
"Invalid marketplace config: missing required :name key in #{inspect(other)}"
end)

settings = Keyword.get(opts, :settings)

case inject_into_settings(settings, "extraKnownMarketplaces", extra) do
{:ok, merged} ->
opts
|> Keyword.delete(:marketplaces)
|> Keyword.put(:settings, merged)

:skip ->
Logger.warning(
"Cannot inject marketplaces into string settings. " <>
"Use a map for :settings or configure marketplaces separately."
)

Keyword.delete(opts, :marketplaces)
end
end
end

# -- Private: plugin preprocessing -------------------------------------------

defp preprocess_plugins(opts) do
case Keyword.get(opts, :plugins) do
nil ->
opts

[] ->
opts

plugins ->
normalized = Enum.map(plugins, &normalize_plugin/1)
{local, marketplace} = Enum.split_with(normalized, &(&1.type == :local))

if marketplace == [] do
Keyword.put(opts, :plugins, local)
else
enabled = Map.new(marketplace, fn %{id: id} -> {id, true} end)
settings = Keyword.get(opts, :settings)

case inject_into_settings(settings, "enabledPlugins", enabled) do
{:ok, merged} ->
opts
|> Keyword.put(:plugins, local)
|> Keyword.put(:settings, merged)

:skip ->
Logger.warning(
"Cannot inject marketplace plugins into string settings. " <>
"Use a map for :settings or configure marketplace plugins separately."
)

Keyword.put(opts, :plugins, local)
end
end
end
end

# -- Private: plugin normalization --------------------------------------------

defp normalize_plugin(path) when is_binary(path) do
# Paths containing "/" or starting with "." are always local,
# even if they contain "@" (e.g., ./plugins/@scope/my-plugin)
if String.contains?(path, "/") or String.starts_with?(path, ".") do
%{type: :local, path: path}
else
if String.contains?(path, "@") do
%{type: :marketplace, id: path}
else
%{type: :local, path: path}
end
end
end

defp normalize_plugin(%{type: _} = map), do: map

defp normalize_plugin(other) do
raise ArgumentError,
"Invalid plugin config: #{inspect(other)}. " <>
"Expected a string path, %{type: :local, path: ...}, or %{type: :marketplace, id: ...}"
end

# -- Private: MCP module subprocess expansion --------------------------------

defp expand_subprocess_module(module, config) do
Expand Down
20 changes: 20 additions & 0 deletions lib/claude_code/mcp/backend.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,24 @@ defmodule ClaudeCode.MCP.Backend do

@doc "Returns true if the given module is compatible with this backend (for subprocess detection)."
@callback compatible?(module :: module()) :: boolean()

# Dispatch to configured implementation

def server_info(server_module), do: impl().server_info(server_module)
def list_tools(server_module), do: impl().list_tools(server_module)

def call_tool(server_module, tool_name, params, assigns),
do: impl().call_tool(server_module, tool_name, params, assigns)

defp impl do
Application.get_env(:claude_code, __MODULE__) || default_impl()
end

defp default_impl do
cond do
Code.ensure_loaded?(ClaudeCode.MCP.Backend.Anubis) -> ClaudeCode.MCP.Backend.Anubis
Code.ensure_loaded?(ClaudeCode.MCP.Backend.Hermes) -> ClaudeCode.MCP.Backend.Hermes
true -> raise "No MCP backend available. Add :anubis_mcp or :hermes_mcp to your deps."
end
end
end
2 changes: 1 addition & 1 deletion lib/claude_code/mcp/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ defmodule ClaudeCode.MCP.Router do
control request from the CLI for a `type: "sdk"` server.
"""

alias ClaudeCode.MCP.Backend.Anubis, as: Backend
alias ClaudeCode.MCP.Backend

@doc """
Handles a JSONRPC request for the given tool server module.
Expand Down
60 changes: 50 additions & 10 deletions lib/claude_code/options.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ defmodule ClaudeCode.Options do
| `setting_sources` | list | - | Setting source priority |
| `include_partial_messages` | boolean | false | Enable character-level streaming |
| `output_format` | map | - | Structured output format (see Structured Outputs section) |
| `plugins` | list | - | Plugin configurations to load (paths or maps with type: :local) |
| `plugins` | list | - | Plugin configurations (local paths or marketplace IDs) |
| `marketplaces` | list | - | Marketplace configurations (injected into settings) |

## Query Options

Expand Down Expand Up @@ -354,24 +355,37 @@ defmodule ClaudeCode.Options do
setting_sources: ["user", "project", "local"]
)

## Plugins
## Plugins and Marketplaces

Load custom plugins to extend Claude's capabilities:
Load plugins to extend Claude's capabilities:

# From a directory path
# Local directory plugins
{:ok, session} = ClaudeCode.start_link(
plugins: ["./my-plugin"]
)

# With explicit type (currently only :local is supported)
# Marketplace plugins (strings with @)
{:ok, session} = ClaudeCode.start_link(
plugins: ["formatter@acme-tools", "linter@security"]
)

# Declarative marketplace + plugin configuration
{:ok, session} = ClaudeCode.start_link(
marketplaces: [
%{name: "acme-tools", source: %{source: "github", repo: "acme/claude-plugins"}}
],
plugins: [
%{type: :local, path: "./my-plugin"},
"./another-plugin"
"./my-local-plugin",
"code-formatter@acme-tools",
%{type: :marketplace, id: "linter@security"}
]
)

For marketplace plugin management (install, enable, disable), see
Marketplace plugin IDs are injected into `enabledPlugins` in the CLI settings.
Marketplace sources are injected into `extraKnownMarketplaces`.
Local paths are passed as `--plugin-dir` flags.

For imperative marketplace plugin management (install, enable, disable), see
`ClaudeCode.Plugin` and `ClaudeCode.Plugin.Marketplace`.

## Runtime Control
Expand Down Expand Up @@ -593,7 +607,29 @@ defmodule ClaudeCode.Options do
],
plugins: [
type: {:list, {:or, [:string, :map]}},
doc: "Plugin configurations - list of paths or maps with type: :local and path keys"
doc: """
Plugin configurations. Accepts a list of:
- Local directory paths: `"./my-plugin"` or `%{type: :local, path: "./my-plugin"}`
- Marketplace plugins: `"name@marketplace"` or `%{type: :marketplace, id: "name@marketplace"}`

Strings containing `/` or starting with `.` are always treated as local paths.
Other strings containing `@` are treated as marketplace plugin IDs and injected into
`enabledPlugins` in the CLI settings. All plugins are enabled by default.
"""
],
marketplaces: [
type: {:list, :map},
doc: """
Marketplace configurations to make available. Each entry is a map with:
- `name` - Marketplace identifier (string)
- `source` - Source config map matching the CLI schema:
- `%{source: "github", repo: "owner/repo"}` — GitHub repository
- `%{source: "url", url: "https://..."}` — Direct URL
- `%{source: "git", url: "https://..."}` — Git repository

Injected into `extraKnownMarketplaces` in settings. Use with `:plugins` to
declaratively configure both marketplace sources and enabled plugins.
"""
],
include_partial_messages: [
type: :boolean,
Expand Down Expand Up @@ -774,7 +810,11 @@ defmodule ClaudeCode.Options do
],
plugins: [
type: {:list, {:or, [:string, :map]}},
doc: "Override plugin configurations for this query"
doc: "Override plugin configurations for this query (local paths or marketplace IDs)"
],
marketplaces: [
type: {:list, :map},
doc: "Override marketplace configurations for this query"
],
include_partial_messages: [
type: :boolean,
Expand Down
5 changes: 3 additions & 2 deletions lib/claude_code/plugin.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ defmodule ClaudeCode.Plugin do
All functions resolve the CLI binary via `ClaudeCode.Adapter.Port.Resolver` and execute
commands synchronously via the system command abstraction.

> **Note:** Remote node support is not yet implemented — these commands run on
> the local machine only.
> **Note:** Remote node support is available via the `node:` option.
> When `config :claude_code, adapter: {ClaudeCode.Adapter.Node, node: ...}` is set,
> commands automatically execute on the remote node.

## Examples

Expand Down
8 changes: 7 additions & 1 deletion lib/claude_code/plugin/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@ defmodule ClaudeCode.Plugin.CLI do
) :: {:ok, term()} | {:error, term()}
def run(args, opts, parse_fn) do
with {:ok, binary} <- Resolver.find_binary(opts) do
case ClaudeCode.System.cmd(binary, args, stderr_to_stdout: true) do
# Pass node: through to System.cmd for remote execution
sys_opts = [stderr_to_stdout: true] ++ Keyword.take(opts, [:node])

case ClaudeCode.System.cmd(binary, args, sys_opts) do
{output, 0} -> parse_fn.(output)
{error_output, _exit_code} -> {:error, String.trim(error_output)}
end
end
rescue
e in RuntimeError ->
{:error, {:remote_error, Exception.message(e)}}
end

@doc """
Expand Down
21 changes: 11 additions & 10 deletions lib/claude_code/plugin/marketplace.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ defmodule ClaudeCode.Plugin.Marketplace do
All functions resolve the CLI binary via `ClaudeCode.Adapter.Port.Resolver` and execute
commands synchronously via the system command abstraction.

> **Note:** Remote node support is not yet implemented — these commands run on
> the local machine only.
> **Note:** Remote node support is available via the `node:` option.
> When `config :claude_code, adapter: {ClaudeCode.Adapter.Node, node: ...}` is set,
> commands automatically execute on the remote node.

## Examples

Expand Down Expand Up @@ -87,26 +88,26 @@ defmodule ClaudeCode.Plugin.Marketplace do

{:ok, _} = ClaudeCode.Plugin.Marketplace.remove("my-marketplace")
"""
@spec remove(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def remove(name) do
PluginCLI.run(["plugin", "marketplace", "remove", name], [], &PluginCLI.ok_trimmed/1)
@spec remove(String.t(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
def remove(name, opts \\ []) do
PluginCLI.run(["plugin", "marketplace", "remove", name], opts, &PluginCLI.ok_trimmed/1)
end

@doc """
Updates marketplace(s) from their source.

When called without a name, updates all marketplaces. When called with a name,
When called with `nil`, updates all marketplaces. When called with a name,
updates only that marketplace.

## Examples

{:ok, _} = ClaudeCode.Plugin.Marketplace.update()
{:ok, _} = ClaudeCode.Plugin.Marketplace.update(nil)
{:ok, _} = ClaudeCode.Plugin.Marketplace.update("my-marketplace")
"""
@spec update(String.t() | nil) :: {:ok, String.t()} | {:error, String.t()}
def update(name \\ nil) do
@spec update(String.t() | nil, keyword()) :: {:ok, String.t()} | {:error, String.t()}
def update(name, opts \\ []) do
args = ["plugin", "marketplace", "update"] ++ if(name, do: [name], else: [])
PluginCLI.run(args, [], &PluginCLI.ok_trimmed/1)
PluginCLI.run(args, opts, &PluginCLI.ok_trimmed/1)
end

# -- Private ----------------------------------------------------------------
Expand Down
Loading
Loading