Current section
Files
Jump to
Current section
Files
lib/asm/extensions/provider_sdk/claude.ex
defmodule ASM.Extensions.ProviderSDK.Claude do
@moduledoc """
Discovery metadata and bridge helpers for the optional Claude-native ASM
extension namespace.
This namespace lives above ASM's normalized kernel.
It does not implement Claude's richer control semantics itself. Instead it:
- publishes discovery metadata for the optional Claude-native surface
- derives `ClaudeAgentSDK.Options` from ASM-style configuration, including
normalized execution-surface data for CLI-backed families
- starts `ClaudeAgentSDK.Client` when callers explicitly opt into the
SDK-local control family
The actual control family remains in `claude_agent_sdk`.
"""
alias ASM.{Error, Options, Provider, ProviderRegistry}
alias ASM.Extensions.ProviderSDK.{Dispatch, Extension, SessionOptions}
@sdk_app :claude_agent_sdk
@sdk_module Module.concat(["ClaudeAgentSDK"])
@sdk_client_module Module.concat(["ClaudeAgentSDK", "Client"])
@sdk_options_module Module.concat(["ClaudeAgentSDK", "Options"])
@hooks_module Module.concat(["ClaudeAgentSDK", "Hooks"])
@permission_module Module.concat(["ClaudeAgentSDK", "Permission"])
@control_protocol_module Module.concat(["ClaudeAgentSDK", "ControlProtocol", "Protocol"])
@asm_derived_sdk_option_keys [
:cwd,
:env,
:path_to_claude_code_executable,
:permission_mode,
:model,
:max_turns,
:timeout_ms
]
@native_capabilities [:control_client, :control_protocol, :hooks, :permission_callbacks]
@native_surface_modules [
@sdk_client_module,
@control_protocol_module,
@hooks_module,
@permission_module
]
@spec extension() :: Extension.t()
def extension do
Extension.new!(
id: :claude,
provider: :claude,
namespace: __MODULE__,
sdk_app: @sdk_app,
sdk_module: @sdk_module,
description: "Optional Claude-native extension namespace above the normalized ASM kernel.",
sdk_available?: available?(),
native_capabilities: @native_capabilities,
native_surface_modules: @native_surface_modules
)
end
@spec available?() :: boolean()
def available?, do: ProviderRegistry.sdk_available?(:claude)
@doc """
Returns the SDK-local Claude client module.
"""
@spec client_module() :: module()
def client_module, do: @sdk_client_module
@spec sdk_app() :: atom()
def sdk_app, do: @sdk_app
@spec sdk_module() :: module()
def sdk_module, do: @sdk_module
@doc """
Returns the Claude SDK control protocol module.
"""
@spec control_protocol_module() :: module()
def control_protocol_module, do: @control_protocol_module
@doc """
Returns the Claude SDK hooks module.
"""
@spec hooks_module() :: module()
def hooks_module, do: @hooks_module
@doc """
Returns the Claude SDK permission module.
"""
@spec permission_module() :: module()
def permission_module, do: @permission_module
@spec native_capabilities() :: [atom()]
def native_capabilities, do: @native_capabilities
@spec native_surface_modules() :: [module()]
def native_surface_modules, do: @native_surface_modules
@doc """
Derives `ClaudeAgentSDK.Options` from ASM-style Claude configuration.
`native_overrides` remains the explicit home for Claude-native options such as
hooks, permission callbacks, SDK MCP servers, file checkpointing, or thinking
configuration. Execution-surface placement stays on `asm_opts`.
"""
@spec sdk_options(keyword(), keyword()) :: {:ok, struct()} | {:error, Error.t()}
def sdk_options(asm_opts, native_overrides \\ [])
when is_list(asm_opts) and is_list(native_overrides) do
with :ok <- ensure_sdk_module(@sdk_options_module, "Claude SDK options"),
{:ok, %{validated: validated, execution_surface: execution_surface}} <-
validate_asm_options(asm_opts),
:ok <- ensure_native_override_boundary(native_overrides),
attrs <- sdk_option_attrs(validated, native_overrides, execution_surface) do
build_sdk_options(attrs)
end
end
@doc """
Derives `ClaudeAgentSDK.Options` from an ASM session plus optional ASM/native
overrides.
"""
@spec sdk_options_for_session(term(), keyword(), keyword()) ::
{:ok, struct()} | {:error, Error.t()}
def sdk_options_for_session(session, asm_overrides \\ [], native_overrides \\ [])
when is_list(asm_overrides) and is_list(native_overrides) do
with {:ok, asm_opts} <- asm_options_from_session(session, asm_overrides) do
sdk_options(asm_opts, native_overrides)
end
end
@doc """
Starts `ClaudeAgentSDK.Client` from ASM-style Claude configuration.
ASM configuration stays on the first argument. Claude-native options live in
`native_overrides`. Direct runtime overrides such as `:execution_surface` or
`:control_request_timeout_ms` live in `client_opts`.
"""
@spec start_client(keyword(), keyword(), keyword()) ::
GenServer.on_start() | {:error, Error.t() | term()}
def start_client(asm_opts, native_overrides \\ [], client_opts \\ [])
when is_list(asm_opts) and is_list(native_overrides) and is_list(client_opts) do
with :ok <- ensure_sdk_module(@sdk_client_module, "Claude SDK client"),
{:ok, options} <- sdk_options(asm_opts, native_overrides) do
start_sdk_client(options, client_opts)
end
end
@doc """
Starts `ClaudeAgentSDK.Client` from an ASM session plus optional ASM/native
overrides.
"""
@spec start_client_for_session(term(), keyword(), keyword(), keyword()) ::
GenServer.on_start() | {:error, Error.t() | term()}
def start_client_for_session(
session,
asm_overrides \\ [],
native_overrides \\ [],
client_opts \\ []
)
when is_list(asm_overrides) and is_list(native_overrides) and is_list(client_opts) do
with {:ok, asm_opts} <- asm_options_from_session(session, asm_overrides) do
start_client(asm_opts, native_overrides, client_opts)
end
end
defp asm_options_from_session(session, asm_overrides) do
case ASM.session_info(session) do
{:ok, %{provider: :claude, options: options}} when is_list(options) ->
{:ok,
options
|> SessionOptions.provider_opts()
|> Keyword.put(:provider, :claude)
|> Keyword.merge(asm_overrides)}
{:ok, %{provider: provider}} ->
{:error,
Error.new(
:config_invalid,
:provider,
"Claude extension requires an ASM Claude session, got #{inspect(provider)}"
)}
{:error, %Error{} = error} ->
{:error, error}
end
end
defp validate_asm_options(asm_opts) do
provider_schema = Provider.resolve!(:claude).options_schema
asm_opts = SessionOptions.provider_opts(asm_opts)
with {:ok, execution_surface, stripped_opts} <-
SessionOptions.extract_execution_surface(asm_opts) do
with :ok <- ensure_claude_provider(stripped_opts),
{:ok, validated} <-
Options.validate(Keyword.put(stripped_opts, :provider, :claude), provider_schema) do
{:ok, %{validated: validated, execution_surface: execution_surface}}
end
end
end
defp ensure_claude_provider(stripped_opts) when is_list(stripped_opts) do
case Keyword.get(stripped_opts, :provider, :claude) do
:claude ->
:ok
other ->
{:error,
Error.new(
:config_invalid,
:provider,
"Claude extension requires provider :claude, got #{inspect(other)}"
)}
end
end
defp sdk_option_attrs(validated, native_overrides, execution_surface) do
validated
|> base_sdk_option_attrs(execution_surface)
|> maybe_add_thinking(validated, native_overrides)
|> Keyword.merge(native_overrides)
end
defp ensure_native_override_boundary(native_overrides) when is_list(native_overrides) do
conflicts =
native_overrides
|> Keyword.keys()
|> Enum.uniq()
|> Enum.filter(&(&1 in @asm_derived_sdk_option_keys))
if conflicts == [] do
:ok
else
{:error,
Error.new(
:config_invalid,
:config,
"Claude native_overrides must not redefine ASM-derived options: " <>
Enum.map_join(conflicts, ", ", &inspect/1) <> ". Set those fields in asm_opts instead.",
cause: conflicts
)}
end
end
defp base_sdk_option_attrs(validated, execution_surface) do
{:ok, finalized} =
Options.finalize_provider_opts(:claude, Keyword.delete(validated, :provider))
model_payload = Keyword.fetch!(finalized, :model_payload)
[
cwd: Keyword.get(finalized, :cwd),
env: Keyword.get(finalized, :env, %{}),
path_to_claude_code_executable: Keyword.get(finalized, :cli_path),
execution_surface: execution_surface,
permission_mode: Keyword.get(finalized, :provider_permission_mode),
model_payload: model_payload,
model: model_payload_value(model_payload, :resolved_model),
max_turns: Keyword.get(finalized, :max_turns),
timeout_ms: Keyword.get(finalized, :transport_timeout_ms)
]
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
end
defp maybe_add_thinking(attrs, validated, native_overrides) do
include_thinking? = Keyword.get(validated, :include_thinking, false)
explicit_thinking? =
Keyword.has_key?(native_overrides, :thinking) or
Keyword.has_key?(native_overrides, :max_thinking_tokens)
if include_thinking? and not explicit_thinking? do
Keyword.put_new(attrs, :thinking, %{type: :adaptive})
else
attrs
end
end
defp build_sdk_options(attrs) do
new_sdk_struct(@sdk_options_module, attrs)
rescue
error ->
{:error, invalid_sdk_options(error)}
end
defp new_sdk_struct(module, attrs) when is_atom(module) do
with :ok <- ensure_sdk_module(module, "Claude SDK options") do
if function_exported?(module, :new, 1) do
{:ok, module.new(attrs)}
else
build_sdk_struct(module, attrs)
end
end
rescue
error in [ArgumentError, KeyError] ->
{:error, invalid_sdk_options(error)}
end
defp build_sdk_struct(module, attrs) when is_atom(module) do
with :ok <- ensure_sdk_module(module, "Claude SDK options") do
{:ok, struct(module, attrs)}
end
rescue
error in [ArgumentError, KeyError] ->
{:error, invalid_sdk_options(error)}
end
defp start_sdk_client(options, client_opts) when is_list(client_opts) do
with :ok <- ensure_sdk_module(@sdk_client_module, "Claude SDK client"),
true <- function_exported?(@sdk_client_module, :start_link, 2) do
module = @sdk_client_module
Dispatch.invoke_2(module, :start_link, options, client_opts)
else
false ->
{:error,
Error.new(
:config_invalid,
:provider,
"Claude SDK client is missing start_link/2",
cause: @sdk_client_module
)}
{:error, %Error{} = error} ->
{:error, error}
end
end
defp invalid_sdk_options(reason) do
Error.new(
:config_invalid,
:config,
"invalid Claude SDK options for ASM extension: #{describe_invalid_sdk_options(reason)}",
cause: reason
)
end
defp describe_invalid_sdk_options(reason) do
Exception.message(reason)
rescue
_error in [Protocol.UndefinedError, FunctionClauseError] ->
inspect(reason)
end
defp ensure_sdk_module(module, label) when is_atom(module) do
if Code.ensure_loaded?(module) do
:ok
else
{:error,
Error.new(
:config_invalid,
:provider,
"#{label} is unavailable because claude_agent_sdk is not loaded",
cause: module
)}
end
end
defp model_payload_value(
%CliSubprocessCore.ModelRegistry.Selection{} = payload,
key
)
when is_atom(key) do
Map.get(payload, key)
end
end