diff --git a/lib/claude_code/adapter/port/resolver.ex b/lib/claude_code/adapter/port/resolver.ex index 84c4fc51..99277783 100644 --- a/lib/claude_code/adapter/port/resolver.ex +++ b/lib/claude_code/adapter/port/resolver.ex @@ -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 diff --git a/lib/claude_code/cli/command.ex b/lib/claude_code/cli/command.ex index e68ecad4..a5647d0b 100644 --- a/lib/claude_code/cli/command.ex +++ b/lib/claude_code/cli/command.ex @@ -11,6 +11,8 @@ defmodule ClaudeCode.CLI.Command do alias ClaudeCode.Session.PermissionMode + require Logger + @required_flags ["--output-format", "stream-json", "--verbose", "--print"] @doc """ @@ -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 -> @@ -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 @@ -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 @@ -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 diff --git a/lib/claude_code/mcp/backend.ex b/lib/claude_code/mcp/backend.ex index e6fb3b43..6a6fca28 100644 --- a/lib/claude_code/mcp/backend.ex +++ b/lib/claude_code/mcp/backend.ex @@ -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 diff --git a/lib/claude_code/mcp/router.ex b/lib/claude_code/mcp/router.ex index b25ff136..a0674c78 100644 --- a/lib/claude_code/mcp/router.ex +++ b/lib/claude_code/mcp/router.ex @@ -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. diff --git a/lib/claude_code/options.ex b/lib/claude_code/options.ex index 3f639158..927765ec 100644 --- a/lib/claude_code/options.ex +++ b/lib/claude_code/options.ex @@ -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 @@ -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 @@ -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, @@ -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, diff --git a/lib/claude_code/plugin.ex b/lib/claude_code/plugin.ex index 6f909943..2535fb83 100644 --- a/lib/claude_code/plugin.ex +++ b/lib/claude_code/plugin.ex @@ -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 diff --git a/lib/claude_code/plugin/cli.ex b/lib/claude_code/plugin/cli.ex index 7bc6edfc..4ecf97c0 100644 --- a/lib/claude_code/plugin/cli.ex +++ b/lib/claude_code/plugin/cli.ex @@ -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 """ diff --git a/lib/claude_code/plugin/marketplace.ex b/lib/claude_code/plugin/marketplace.ex index c58a4f78..a59b9f36 100644 --- a/lib/claude_code/plugin/marketplace.ex +++ b/lib/claude_code/plugin/marketplace.ex @@ -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 @@ -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 ---------------------------------------------------------------- diff --git a/lib/claude_code/system.ex b/lib/claude_code/system.ex index 8e0a864d..b1bbdb9a 100644 --- a/lib/claude_code/system.ex +++ b/lib/claude_code/system.ex @@ -1,14 +1,29 @@ defmodule ClaudeCode.System do @moduledoc false - @doc "Behaviour for wrapping System.cmd/3, allowing test mocking." + @doc "Behaviour for wrapping System.cmd/3 and System.find_executable/1, allowing test mocking." @callback cmd(binary(), [binary()], keyword()) :: {binary(), non_neg_integer()} + @callback find_executable(binary()) :: String.t() | nil def cmd(command, args, opts \\ []) do impl().cmd(command, args, opts) end + def find_executable(name) do + impl().find_executable(name) + end + defp impl do - Application.get_env(:claude_code, ClaudeCode.System, ClaudeCode.System.Default) + case Application.get_env(:claude_code, ClaudeCode.System) do + nil -> impl_from_adapter() + module -> module + end + end + + defp impl_from_adapter do + case Application.get_env(:claude_code, :adapter) do + {ClaudeCode.Adapter.Node, _} -> ClaudeCode.System.Remote + _ -> ClaudeCode.System.Default + end end end diff --git a/lib/claude_code/system/default.ex b/lib/claude_code/system/default.ex index 68cf23c8..1047981b 100644 --- a/lib/claude_code/system/default.ex +++ b/lib/claude_code/system/default.ex @@ -6,4 +6,9 @@ defmodule ClaudeCode.System.Default do def cmd(command, args, opts) do System.cmd(command, args, opts) end + + @impl true + def find_executable(name) do + System.find_executable(name) + end end diff --git a/lib/claude_code/system/remote.ex b/lib/claude_code/system/remote.ex new file mode 100644 index 00000000..897ffc97 --- /dev/null +++ b/lib/claude_code/system/remote.ex @@ -0,0 +1,48 @@ +defmodule ClaudeCode.System.Remote do + @moduledoc false + @behaviour ClaudeCode.System + + require Logger + + @impl true + def cmd(command, args, opts) do + {node_opt, sys_opts} = Keyword.pop(opts, :node) + node = node_opt || node_from_adapter_config() + + Logger.debug("Executing remote command on #{node}: #{command} #{inspect(args)}") + + case :rpc.call(node, System, :cmd, [command, args, sys_opts]) do + {:badrpc, reason} -> + raise "Remote command failed on #{node}: #{inspect(reason)}" + + result -> + result + end + end + + @impl true + def find_executable(name) do + node = node_from_adapter_config() + + case :rpc.call(node, System, :find_executable, [name]) do + {:badrpc, reason} -> + raise "Remote find_executable failed on #{node}: #{inspect(reason)}" + + result -> + result + end + end + + defp node_from_adapter_config do + case Application.get_env(:claude_code, :adapter) do + {ClaudeCode.Adapter.Node, opts} -> + Keyword.fetch!(opts, :node) + + other -> + raise ArgumentError, + "No :node option provided and no adapter node configured. " <> + "Pass node: explicitly or configure adapter: {ClaudeCode.Adapter.Node, node: ...}. " <> + "Got adapter config: #{inspect(other)}" + end + end +end diff --git a/mix.exs b/mix.exs index 78b81001..92d3cbcc 100644 --- a/mix.exs +++ b/mix.exs @@ -258,7 +258,9 @@ defmodule ClaudeCode.MixProject do "MCP Integration": ~r/ClaudeCode\.MCP/, Testing: [ ClaudeCode.Test, - ClaudeCode.Test.Factory + ClaudeCode.Test.Factory, + ClaudeCode.Hook.DebugLogger, + ClaudeCode.Hook.DebugLogger.Permissive ], Installation: [ ClaudeCode.Adapter.Port.Installer, diff --git a/test/claude_code/adapter/port/resolver_test.exs b/test/claude_code/adapter/port/resolver_test.exs index b693091b..839147f9 100644 --- a/test/claude_code/adapter/port/resolver_test.exs +++ b/test/claude_code/adapter/port/resolver_test.exs @@ -1,5 +1,5 @@ defmodule ClaudeCode.Adapter.Port.ResolverTest do - use ExUnit.Case, async: true + use ClaudeCode.Case, async: true alias ClaudeCode.Adapter.Port.Resolver @@ -24,8 +24,9 @@ defmodule ClaudeCode.Adapter.Port.ResolverTest do end describe "find_binary/1 with :global mode" do + @tag :real_system test "returns :not_found when claude is not installed globally" do - original = Application.get_env(:claude_code, :cli_path) + original_path = Application.get_env(:claude_code, :cli_path) try do Application.delete_env(:claude_code, :cli_path) @@ -40,7 +41,7 @@ defmodule ClaudeCode.Adapter.Port.ResolverTest do assert true end after - if original, do: Application.put_env(:claude_code, :cli_path, original) + if original_path, do: Application.put_env(:claude_code, :cli_path, original_path) end end end diff --git a/test/claude_code/cli/command_test.exs b/test/claude_code/cli/command_test.exs index 893b082b..69e6f0fa 100644 --- a/test/claude_code/cli/command_test.exs +++ b/test/claude_code/cli/command_test.exs @@ -925,6 +925,194 @@ defmodule ClaudeCode.CLI.CommandTest do assert plugin_count == 2 end + # -- Plugin normalization (marketplace vs local) -------------------------- + + test "normalizes marketplace plugin strings (with @) and excludes from CLI flags" do + args = Command.to_cli_args(plugins: ["formatter@acme-tools", "./local-plugin"]) + + assert "--plugin-dir" in args + assert "./local-plugin" in args + # Marketplace plugins should NOT produce --plugin-dir flags + refute "formatter@acme-tools" in args + end + + test "handles mixed local and marketplace plugin maps" do + plugins = [ + %{type: :local, path: "./my-plugin"}, + %{type: :marketplace, id: "linter@security"} + ] + + args = Command.to_cli_args(plugins: plugins) + + assert "--plugin-dir" in args + assert "./my-plugin" in args + refute "linter@security" in args + end + + test "paths containing / are always treated as local even with @" do + args = Command.to_cli_args(plugins: ["./plugins/@scope/my-plugin"]) + + assert "--plugin-dir" in args + assert "./plugins/@scope/my-plugin" in args + end + + test "paths starting with . are always treated as local even with @" do + args = Command.to_cli_args(plugins: ["./@scoped-plugin"]) + + assert "--plugin-dir" in args + assert "./@scoped-plugin" in args + end + + test "raises ArgumentError on invalid plugin config" do + assert_raise ArgumentError, ~r/Invalid plugin config/, fn -> + Command.to_cli_args(plugins: [42]) + end + end + + # -- Marketplace plugin settings injection -------------------------------- + + test "marketplace plugins are injected into settings as enabledPlugins" do + args = Command.to_cli_args( + plugins: ["formatter@acme-tools", "linter@security", "./local"] + ) + + settings_idx = Enum.find_index(args, &(&1 == "--settings")) + assert settings_idx != nil + + settings_json = Enum.at(args, settings_idx + 1) + settings = Jason.decode!(settings_json) + assert settings["enabledPlugins"] == %{ + "formatter@acme-tools" => true, + "linter@security" => true + } + + assert "--plugin-dir" in args + assert "./local" in args + end + + test "marketplace plugins merge with existing settings map" do + args = Command.to_cli_args( + plugins: ["formatter@acme"], + settings: %{"model" => "opus"} + ) + + settings_idx = Enum.find_index(args, &(&1 == "--settings")) + settings_json = Enum.at(args, settings_idx + 1) + settings = Jason.decode!(settings_json) + + assert settings["enabledPlugins"] == %{"formatter@acme" => true} + assert settings["model"] == "opus" + end + + test "no settings injection when only local plugins" do + args = Command.to_cli_args(plugins: ["./local-only"]) + + case Enum.find_index(args, &(&1 == "--settings")) do + nil -> :ok + idx -> + settings_json = Enum.at(args, idx + 1) + settings = Jason.decode!(settings_json) + refute Map.has_key?(settings, "enabledPlugins") + end + end + + test "string settings are preserved when marketplace plugins present" do + import ExUnit.CaptureLog + + log = capture_log(fn -> + args = Command.to_cli_args( + plugins: ["formatter@acme"], + settings: "/path/to/settings.json" + ) + + settings_idx = Enum.find_index(args, &(&1 == "--settings")) + assert settings_idx != nil + assert Enum.at(args, settings_idx + 1) == "/path/to/settings.json" + end) + + assert log =~ "Cannot inject marketplace plugins" + end + + # -- Marketplace settings injection ---------------------------------------- + + test "marketplaces are injected into settings as extraKnownMarketplaces" do + args = Command.to_cli_args( + marketplaces: [ + %{name: "acme", source: %{source: "github", repo: "acme/plugins"}} + ] + ) + + settings_idx = Enum.find_index(args, &(&1 == "--settings")) + assert settings_idx != nil + + settings_json = Enum.at(args, settings_idx + 1) + settings = Jason.decode!(settings_json) + assert settings["extraKnownMarketplaces"]["acme"] == %{ + "source" => %{"source" => "github", "repo" => "acme/plugins"} + } + end + + test "marketplaces merge with existing settings" do + args = Command.to_cli_args( + marketplaces: [ + %{name: "acme", source: %{source: "github", repo: "acme/plugins"}} + ], + settings: %{"model" => "opus"} + ) + + settings_idx = Enum.find_index(args, &(&1 == "--settings")) + settings_json = Enum.at(args, settings_idx + 1) + settings = Jason.decode!(settings_json) + + assert settings["extraKnownMarketplaces"]["acme"] != nil + assert settings["model"] == "opus" + end + + test "combined marketplaces and plugins inject both settings keys" do + args = Command.to_cli_args( + marketplaces: [ + %{name: "acme", source: %{source: "github", repo: "acme/plugins"}} + ], + plugins: ["formatter@acme", "./local"] + ) + + settings_idx = Enum.find_index(args, &(&1 == "--settings")) + settings_json = Enum.at(args, settings_idx + 1) + settings = Jason.decode!(settings_json) + + assert settings["extraKnownMarketplaces"]["acme"] != nil + assert settings["enabledPlugins"] == %{"formatter@acme" => true} + assert "--plugin-dir" in args + assert "./local" in args + end + + test "string settings are preserved when marketplaces present" do + import ExUnit.CaptureLog + + log = capture_log(fn -> + args = Command.to_cli_args( + marketplaces: [ + %{name: "acme", source: %{source: "github", repo: "acme/plugins"}} + ], + settings: "/path/to/settings.json" + ) + + settings_idx = Enum.find_index(args, &(&1 == "--settings")) + assert settings_idx != nil + assert Enum.at(args, settings_idx + 1) == "/path/to/settings.json" + end) + + assert log =~ "Cannot inject marketplaces" + end + + test "raises ArgumentError when marketplace map is missing :name" do + assert_raise ArgumentError, ~r/missing required :name key/, fn -> + Command.to_cli_args( + marketplaces: [%{source: %{source: "github", repo: "acme/plugins"}}] + ) + end + end + # -- File options -------------------------------------------------------- test "converts file list to multiple --file flags" do diff --git a/test/claude_code/plugin/marketplace_test.exs b/test/claude_code/plugin/marketplace_test.exs index 054eea43..a5bf178f 100644 --- a/test/claude_code/plugin/marketplace_test.exs +++ b/test/claude_code/plugin/marketplace_test.exs @@ -1,24 +1,12 @@ defmodule ClaudeCode.Plugin.MarketplaceTest do - use ExUnit.Case - - import Mox + use ClaudeCode.Case alias ClaudeCode.Plugin.Marketplace alias ClaudeCode.System.Mock - setup :verify_on_exit! - - @cli_path System.find_executable("true") + @moduletag :mock_system - setup do - Application.put_env(:claude_code, ClaudeCode.System, Mock) - Application.put_env(:claude_code, :cli_path, @cli_path) - - on_exit(fn -> - Application.delete_env(:claude_code, ClaudeCode.System) - Application.put_env(:claude_code, :cli_path, "/nonexistent/test/claude") - end) - end + setup :verify_on_exit! describe "struct" do test "has expected fields" do @@ -89,6 +77,15 @@ defmodule ClaudeCode.Plugin.MarketplaceTest do assert {:error, msg} = Marketplace.list() assert msg =~ "Failed to parse marketplace list JSON" end + + test "passes node: option through to System.cmd" do + expect(Mock, :cmd, fn _binary, ["plugin", "marketplace", "list", "--json"], opts -> + assert Keyword.get(opts, :node) == :"test@node" + {"[]", 0} + end) + + assert {:ok, []} = Marketplace.list(node: :"test@node") + end end describe "add/2" do @@ -132,7 +129,7 @@ defmodule ClaudeCode.Plugin.MarketplaceTest do end end - describe "remove/1" do + describe "remove/2" do test "removes a marketplace by name" do expect(Mock, :cmd, fn _binary, args, _opts -> assert args == ["plugin", "marketplace", "remove", "my-marketplace"] @@ -149,16 +146,25 @@ defmodule ClaudeCode.Plugin.MarketplaceTest do assert {:error, "Error: Marketplace not found"} = Marketplace.remove("nonexistent") end + + test "passes node: through to System.cmd" do + expect(Mock, :cmd, fn _binary, ["plugin", "marketplace", "remove", "my-marketplace"], opts -> + assert Keyword.get(opts, :node) == :"test@node" + {"Removed", 0} + end) + + assert {:ok, "Removed"} = Marketplace.remove("my-marketplace", node: :"test@node") + end end - describe "update/1" do - test "updates all marketplaces when no name given" do + describe "update/2" do + test "updates all marketplaces when nil name given" do expect(Mock, :cmd, fn _binary, args, _opts -> assert args == ["plugin", "marketplace", "update"] {"✔ Updated all\n", 0} end) - assert {:ok, _} = Marketplace.update() + assert {:ok, _} = Marketplace.update(nil) end test "updates a specific marketplace" do @@ -169,5 +175,14 @@ defmodule ClaudeCode.Plugin.MarketplaceTest do assert {:ok, _} = Marketplace.update("my-marketplace") end + + test "passes node: through to System.cmd" do + expect(Mock, :cmd, fn _binary, ["plugin", "marketplace", "update", "my-marketplace"], opts -> + assert Keyword.get(opts, :node) == :"test@node" + {"Updated", 0} + end) + + assert {:ok, "Updated"} = Marketplace.update("my-marketplace", node: :"test@node") + end end end diff --git a/test/claude_code/plugin_test.exs b/test/claude_code/plugin_test.exs index a0622f87..9849a2a5 100644 --- a/test/claude_code/plugin_test.exs +++ b/test/claude_code/plugin_test.exs @@ -1,26 +1,12 @@ defmodule ClaudeCode.PluginTest do - use ExUnit.Case - - import Mox + use ClaudeCode.Case alias ClaudeCode.Plugin alias ClaudeCode.System.Mock - setup :verify_on_exit! - - # Point cli_path at a real executable so the resolver's File.exists? check - # passes. The System.cmd mock intercepts the actual command execution. - @cli_path System.find_executable("true") - - setup do - Application.put_env(:claude_code, ClaudeCode.System, Mock) - Application.put_env(:claude_code, :cli_path, @cli_path) + @moduletag :mock_system - on_exit(fn -> - Application.delete_env(:claude_code, ClaudeCode.System) - Application.put_env(:claude_code, :cli_path, "/nonexistent/test/claude") - end) - end + setup :verify_on_exit! describe "struct" do test "has expected fields" do @@ -107,6 +93,23 @@ defmodule ClaudeCode.PluginTest do assert {:error, "Error: something went wrong"} = Plugin.list() end + test "passes node: option through to System.cmd" do + expect(Mock, :cmd, fn _binary, ["plugin", "list", "--json"], opts -> + assert Keyword.get(opts, :node) == :"test@node" + {"[]", 0} + end) + + assert {:ok, []} = Plugin.list(node: :"test@node") + end + + test "returns {:error, {:remote_error, reason}} on remote command failure" do + expect(Mock, :cmd, fn _binary, _args, _opts -> + raise "Remote command failed on test@node: :nodedown" + end) + + assert {:error, {:remote_error, _reason}} = Plugin.list(node: :"test@node") + end + test "returns error on invalid JSON" do expect(Mock, :cmd, fn _binary, _args, _opts -> {"not valid json", 0} end) diff --git a/test/claude_code/system/remote_test.exs b/test/claude_code/system/remote_test.exs new file mode 100644 index 00000000..dbb394dd --- /dev/null +++ b/test/claude_code/system/remote_test.exs @@ -0,0 +1,51 @@ +defmodule ClaudeCode.System.RemoteTest do + use ExUnit.Case, async: false + + alias ClaudeCode.System.Remote + + describe "cmd/3" do + test "calls :rpc.call with the node from opts" do + result = Remote.cmd("echo", ["hello"], node: node()) + assert {"hello\n", 0} = result + end + + test "raises on badrpc" do + assert_raise RuntimeError, ~r/Remote command failed/, fn -> + Remote.cmd("echo", ["hello"], node: :"nonexistent@nowhere") + end + end + + test "strips :node from opts before passing to System.cmd" do + result = Remote.cmd("echo", ["test"], node: node(), stderr_to_stdout: true) + assert {"test\n", 0} = result + end + end + + describe "node_from_adapter_config/0" do + test "falls back to adapter config when no node opt" do + prev = Application.get_env(:claude_code, :adapter) + Application.put_env(:claude_code, :adapter, {ClaudeCode.Adapter.Node, [node: node()]}) + + try do + result = Remote.cmd("echo", ["from_config"], []) + assert {"from_config\n", 0} = result + after + if prev, do: Application.put_env(:claude_code, :adapter, prev), + else: Application.delete_env(:claude_code, :adapter) + end + end + + test "raises descriptive error when no node configured" do + prev = Application.get_env(:claude_code, :adapter) + Application.delete_env(:claude_code, :adapter) + + try do + assert_raise ArgumentError, ~r/No :node option provided/, fn -> + Remote.cmd("echo", ["hello"], []) + end + after + if prev, do: Application.put_env(:claude_code, :adapter, prev) + end + end + end +end diff --git a/test/support/claude_code_case.ex b/test/support/claude_code_case.ex new file mode 100644 index 00000000..aa938a53 --- /dev/null +++ b/test/support/claude_code_case.ex @@ -0,0 +1,74 @@ +defmodule ClaudeCode.Case do + @moduledoc """ + Test case template for ClaudeCode tests that need System mock or app config management. + + ## Tags + + ### `@moduletag :mock_system` / `@tag :mock_system` + + Sets up `ClaudeCode.System.Mock` and a valid `cli_path` so that `Plugin.CLI.run/3` + and `Resolver.find_binary/1` work without a real CLI binary. Auto-cleanup on exit. + + describe "plugin commands" do + @describetag :mock_system + + test "lists plugins", %{cli_path: cli_path} do + expect(ClaudeCode.System.Mock, :cmd, fn _binary, _args, _opts -> {"[]", 0} end) + assert {:ok, []} = Plugin.list() + end + end + + ### `@moduletag :real_system` / `@tag :real_system` + + Ensures `ClaudeCode.System` is NOT mocked — clears any mock set by other async tests. + Use this when testing code that must call real `System.find_executable/1` or `System.cmd/3`. + + @tag :real_system + test "finds real binary" do + # Uses real System, not mock + end + """ + + use ExUnit.CaseTemplate + + @cli_path System.find_executable("true") + + using do + quote do + import Mox + end + end + + setup tags do + if tags[:mock_system] do + setup_mock_system() + end + + if tags[:real_system] do + setup_real_system() + end + + :ok + end + + defp setup_mock_system do + Application.put_env(:claude_code, ClaudeCode.System, ClaudeCode.System.Mock) + Application.put_env(:claude_code, :cli_path, @cli_path) + + ExUnit.Callbacks.on_exit(fn -> + Application.delete_env(:claude_code, ClaudeCode.System) + Application.put_env(:claude_code, :cli_path, "/nonexistent/test/claude") + end) + end + + defp setup_real_system do + prev = Application.get_env(:claude_code, ClaudeCode.System) + Application.delete_env(:claude_code, ClaudeCode.System) + + ExUnit.Callbacks.on_exit(fn -> + if prev do + Application.put_env(:claude_code, ClaudeCode.System, prev) + end + end) + end +end