Packages
claude_code
0.36.4
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.0
0.34.0
0.33.1
0.32.2
0.32.0
0.31.0
0.30.0
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.1
0.8.0
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
Claude Agent SDK for Elixir – Build AI agents with Claude Code
Current section
Files
Jump to
Current section
Files
lib/claude_code/cli/command.ex
defmodule ClaudeCode.CLI.Command do
@moduledoc """
Builds CLI command arguments from validated options.
This module is the CLI protocol layer responsible for:
- Converting Elixir options to CLI flags (`to_cli_args/1`)
- Assembling the full argument list for a CLI invocation (`build_args/3`)
It is used by `ClaudeCode.Adapter.Port` for local subprocess management.
"""
alias ClaudeCode.Session.PermissionMode
require Logger
@required_flags ["--output-format", "stream-json", "--verbose", "--print"]
@doc """
Builds the full argument list for a CLI invocation.
Combines required flags, optional `--resume` flag, option-derived CLI flags,
and the prompt into a single flat list of strings.
## Parameters
* `prompt` - The user prompt string (appended as the final argument)
* `opts` - Validated option keyword list
* `session_id` - Optional session ID for `--resume` (nil to omit)
## Examples
iex> ClaudeCode.CLI.Command.build_args("hello", [model: "opus"], nil)
["--output-format", "stream-json", "--verbose", "--print", "--model", "opus", "hello"]
iex> ClaudeCode.CLI.Command.build_args("hello", [], "sess-123")
["--output-format", "stream-json", "--verbose", "--print", "--resume", "sess-123", "hello"]
"""
@spec build_args(String.t(), keyword(), String.t() | nil) :: [String.t()]
def build_args(prompt, opts, session_id) do
resume_args = if session_id, do: ["--resume", session_id], else: []
option_args = to_cli_args(opts)
@required_flags ++ resume_args ++ option_args ++ [prompt]
end
@doc """
Converts Elixir options to CLI arguments.
Ignores internal options like :api_key, :name, and :timeout that are not CLI flags.
## Examples
iex> ClaudeCode.CLI.Command.to_cli_args([system_prompt: "You are helpful"])
["--system-prompt", "You are helpful"]
iex> ClaudeCode.CLI.Command.to_cli_args([allowed_tools: ["View", "Bash(git:*)"]])
["--allowedTools", "View,Bash(git:*)"]
"""
@spec to_cli_args(keyword()) :: [String.t()]
def to_cli_args(opts) do
opts
|> preprocess_sandbox()
|> preprocess_thinking()
|> ensure_setting_sources()
|> Enum.reduce([], fn {key, value}, acc ->
case convert_option(key, value) do
{flag, flag_value} -> [flag_value, flag | acc]
# Acc is built in reverse (flipped by Enum.reverse at the end),
# so multi-entry lists must also be reversed before prepending.
flag_entries when is_list(flag_entries) -> Enum.reverse(flag_entries, acc)
nil -> acc
end
end)
|> Enum.reverse(build_extra_args(Keyword.get(opts, :extra_args, %{})))
end
# -- Private: option conversion ---------------------------------------------
defp convert_option(:api_key, _value), do: nil
defp convert_option(:name, _value), do: nil
defp convert_option(:timeout, _value), do: nil
defp convert_option(:hooks, _value), do: nil
defp convert_option(:resume, _value), do: nil
defp convert_option(:adapter, _value), do: nil
defp convert_option(:cli_path, _value), do: nil
defp convert_option(:fork_session, true) do
# Boolean flag without value - return as list to be flattened
["--fork-session"]
end
defp convert_option(:fork_session, false), do: nil
defp convert_option(:continue, true) do
# Boolean flag without value - return as list to be flattened
["--continue"]
end
defp convert_option(:continue, false), do: nil
defp convert_option(:can_use_tool, nil), do: nil
defp convert_option(:can_use_tool, _value), do: {"--permission-prompt-tool", "stdio"}
defp convert_option(_key, nil), do: nil
# TypeScript SDK aligned options
defp convert_option(:system_prompt, value) do
{"--system-prompt", to_string(value)}
end
defp convert_option(:append_system_prompt, value) do
{"--append-system-prompt", to_string(value)}
end
defp convert_option(:max_turns, value) do
{"--max-turns", to_string(value)}
end
defp convert_option(:max_budget_usd, value) do
{"--max-budget-usd", to_string(value)}
end
defp convert_option(:max_thinking_tokens, value) do
{"--max-thinking-tokens", to_string(value)}
end
defp convert_option(:effort, value) do
{"--effort", to_string(value)}
end
defp convert_option(:agent, value) do
{"--agent", to_string(value)}
end
defp convert_option(:betas, value) when is_list(value) do
if value == [] do
nil
else
Enum.flat_map(value, fn beta -> ["--betas", to_string(beta)] end)
end
end
defp convert_option(:tools, :default) do
{"--tools", "default"}
end
defp convert_option(:tools, []) do
# Empty list means no built-in tools - CLI accepts "" to disable all
{"--tools", ""}
end
defp convert_option(:tools, value) when is_list(value) do
tools_csv = Enum.join(value, ",")
{"--tools", tools_csv}
end
defp convert_option(:allowed_tools, value) when is_list(value) do
tools_csv = Enum.join(value, ",")
{"--allowedTools", tools_csv}
end
defp convert_option(:disallowed_tools, value) when is_list(value) do
tools_csv = Enum.join(value, ",")
{"--disallowedTools", tools_csv}
end
defp convert_option(:cwd, _value) do
# cwd is handled internally by changing working directory when spawning CLI process
# It's not passed as a CLI flag since the CLI doesn't support --cwd
nil
end
defp convert_option(:mcp_config, value) do
{"--mcp-config", to_string(value)}
end
defp convert_option(:permission_prompt_tool, value) do
{"--permission-prompt-tool", to_string(value)}
end
defp convert_option(:model, value) do
{"--model", to_string(value)}
end
defp convert_option(:fallback_model, value) do
{"--fallback-model", to_string(value)}
end
defp convert_option(:permission_mode, mode) do
{"--permission-mode", PermissionMode.encode(mode)}
end
defp convert_option(:add_dir, value) when is_list(value) do
if value == [] do
nil
else
# Return a flat list of alternating flags and values
Enum.flat_map(value, fn dir -> ["--add-dir", to_string(dir)] end)
end
end
defp convert_option(:output_format, %{type: :json_schema, schema: schema}) when is_map(schema) do
json_string = Jason.encode!(schema)
{"--json-schema", json_string}
end
defp convert_option(:output_format, _value), do: nil
defp convert_option(:settings, value) when is_map(value) do
json_string = Jason.encode!(value)
{"--settings", json_string}
end
defp convert_option(:settings, value) do
{"--settings", to_string(value)}
end
defp convert_option(:setting_sources, value) when is_list(value) do
sources_csv = Enum.join(value, ",")
{"--setting-sources", sources_csv}
end
defp convert_option(:plugins, value) when is_list(value) do
if value == [] do
nil
else
# Extract path from each plugin config (string path or map with type: :local)
Enum.flat_map(value, fn
path when is_binary(path) ->
["--plugin-dir", path]
%{type: :local, path: path} ->
["--plugin-dir", to_string(path)]
_other ->
[]
end)
end
end
# :agents is sent via the control protocol initialize handshake, not as a CLI flag
defp convert_option(:agents, _value), do: nil
defp convert_option(:mcp_servers, value) when is_map(value) do
expanded =
Map.new(value, fn
{name, module} when is_atom(module) ->
{name, expand_mcp_module(name, module, %{})}
{name, %{module: module} = config} when is_atom(module) ->
{name, expand_mcp_module(name, module, config)}
{name, %{"module" => module} = config} when is_atom(module) ->
{name, expand_mcp_module(name, module, config)}
{name, config} when is_map(config) ->
{name, config}
end)
# CLI expects mcpServers wrapper format via --mcp-config flag
json_string = Jason.encode!(%{mcpServers: expanded})
{"--mcp-config", json_string}
end
defp convert_option(:include_partial_messages, true) do
# Boolean flag without value - return as list to be flattened
["--include-partial-messages"]
end
defp convert_option(:include_partial_messages, false), do: nil
defp convert_option(:replay_user_messages, true) do
["--replay-user-messages"]
end
defp convert_option(:replay_user_messages, false), do: nil
defp convert_option(:strict_mcp_config, true) do
# Boolean flag without value - return as list to be flattened
["--strict-mcp-config"]
end
defp convert_option(:strict_mcp_config, false), do: nil
defp convert_option(:allow_dangerously_skip_permissions, true) do
["--allow-dangerously-skip-permissions"]
end
defp convert_option(:allow_dangerously_skip_permissions, false), do: nil
defp convert_option(:dangerously_skip_permissions, true) do
["--dangerously-skip-permissions"]
end
defp convert_option(:dangerously_skip_permissions, false), do: nil
defp convert_option(:disable_slash_commands, true) do
["--disable-slash-commands"]
end
defp convert_option(:disable_slash_commands, false), do: nil
defp convert_option(:no_session_persistence, true) do
["--no-session-persistence"]
end
defp convert_option(:no_session_persistence, false), do: nil
defp convert_option(:session_id, value) do
{"--session-id", value}
end
defp convert_option(:file, value) when is_list(value) do
if value == [] do
nil
else
Enum.flat_map(value, fn spec -> ["--file", to_string(spec)] end)
end
end
defp convert_option(:from_pr, value) do
{"--from-pr", to_string(value)}
end
defp convert_option(:debug, true), do: ["--debug"]
defp convert_option(:debug, false), do: nil
defp convert_option(:debug, value) when is_binary(value) do
{"--debug", value}
end
defp convert_option(:debug_file, value) do
{"--debug-file", to_string(value)}
end
defp convert_option(:input_format, :text) do
{"--input-format", "text"}
end
defp convert_option(:input_format, :stream_json) do
{"--input-format", "stream-json"}
end
defp convert_option(:worktree, true), do: ["--worktree"]
defp convert_option(:worktree, false), do: nil
defp convert_option(:worktree, name) when is_binary(name) do
{"--worktree", name}
end
defp convert_option(:resume_session_at, value) do
{"--resume-session-at", to_string(value)}
end
# Internal options - not passed as CLI flags
# :sandbox is preprocessed into :settings; :thinking is preprocessed into :max_thinking_tokens
# :enable_file_checkpointing is set via env var
# :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(:thinking, _value), do: nil
defp convert_option(:enable_file_checkpointing, _value), do: nil
defp convert_option(:control_timeout, _value), do: nil
defp convert_option(:callers, _value), do: nil
defp convert_option(:stub_name, _value), do: nil
defp convert_option(:env, _value), do: nil
defp convert_option(:inherit_env, _value), do: nil
defp convert_option(:max_buffer_size, _value), do: nil
defp convert_option(:extra_args, _value), do: nil
defp convert_option(:prompt_suggestions, _value), do: nil
defp convert_option(:tool_config, _value), do: nil
# If this fires, a new option was added to Options but not handled here.
defp convert_option(key, _value) do
Logger.warning("ClaudeCode.CLI.Command: unknown option #{inspect(key)} — needs a convert_option clause")
nil
end
# -- Private: extra args conversion ------------------------------------------
defp build_extra_args(extra_args) when map_size(extra_args) == 0, do: []
defp build_extra_args(extra_args) do
Enum.flat_map(extra_args, fn
{flag, true} -> [flag]
{flag, value} -> [flag, value]
end)
end
# -- Private: setting sources preprocessing ----------------------------------
# Always send --setting-sources to match Python SDK behavior.
# When not explicitly configured, send "" to prevent default loading.
defp ensure_setting_sources(opts) do
if Keyword.has_key?(opts, :setting_sources) do
opts
else
Keyword.put(opts, :setting_sources, [])
end
end
# -- Private: thinking preprocessing ----------------------------------------
# Resolves :thinking config into :max_thinking_tokens.
# :thinking takes precedence over the deprecated :max_thinking_tokens.
defp preprocess_thinking(opts) do
case Keyword.pop(opts, :thinking) do
{nil, opts} ->
opts
{:adaptive, opts} ->
resolved = Keyword.get(opts, :max_thinking_tokens) || 32_000
Keyword.put(opts, :max_thinking_tokens, resolved)
{:disabled, opts} ->
Keyword.put(opts, :max_thinking_tokens, 0)
{{:enabled, thinking_opts}, opts} when is_list(thinking_opts) ->
Keyword.put(opts, :max_thinking_tokens, Keyword.fetch!(thinking_opts, :budget_tokens))
{_unknown, opts} ->
opts
end
end
# -- Private: sandbox preprocessing ----------------------------------------
defp preprocess_sandbox(opts) do
case Keyword.pop(opts, :sandbox) do
{nil, opts} ->
opts
{%ClaudeCode.Sandbox{} = sandbox, opts} ->
sandbox_map = ClaudeCode.Sandbox.to_settings_map(sandbox)
merged = merge_sandbox_into_settings(Keyword.get(opts, :settings), sandbox_map)
Keyword.put(opts, :settings, merged)
end
end
defp merge_sandbox_into_settings(nil, sandbox_map), do: %{"sandbox" => sandbox_map}
defp merge_sandbox_into_settings(settings, sandbox_map) when is_map(settings) do
Map.put(settings, "sandbox", sandbox_map)
end
defp merge_sandbox_into_settings(json_string, sandbox_map) when is_binary(json_string) do
case Jason.decode(json_string) do
{:ok, decoded} -> Map.put(decoded, "sandbox", sandbox_map)
{:error, _} -> %{"sandbox" => sandbox_map}
end
end
# -- Private: MCP module subprocess expansion --------------------------------
defp expand_subprocess_module(module, config) do
# Generate stdio command config for an MCP server module
# This allows the CLI to spawn the Elixir app with the MCP server
startup_code = "#{inspect(module)}.start_link(transport: :stdio)"
# Extract custom env from config (supports both atom and string keys)
custom_env = config[:env] || config["env"] || %{}
merged_env = Map.merge(%{"MIX_ENV" => "prod"}, custom_env)
%{
command: "mix",
args: ["run", "--no-halt", "-e", startup_code],
env: merged_env
}
end
defp expand_mcp_module(name, module, config) do
case ClaudeCode.MCP.backend_for(module) do
:sdk ->
%{type: "sdk", name: name}
{:subprocess, _backend} ->
expand_subprocess_module(module, config)
:unknown ->
raise ArgumentError,
"Module #{inspect(module)} passed to :mcp_servers is not a recognized MCP server module"
end
end
end