Current section

Files

Jump to
claude_wrapper lib claude_wrapper.ex
Raw

lib/claude_wrapper.ex

defmodule ClaudeWrapper do
@moduledoc """
Elixir wrapper for the Claude Code CLI.
Two ways to drive `claude` from Elixir:
1. `ClaudeWrapper.DuplexSession` -- a `GenServer` that holds **one**
`claude` subprocess open for the lifetime of a conversation.
Streams partial tokens as they arrive, supports clean mid-turn
cancellation, and routes tool-permission prompts back to the
host. The right fit for chat UIs, agent runtimes, Phoenix
servers, and any long-running OTP host.
2. The convenience `ClaudeWrapper.query/2` and `ClaudeWrapper.stream/2`
functions in this module -- one subprocess per turn, simple
request/response. The right fit for `mix` tasks, escripts, batch
jobs, and anything else that runs and exits.
Both modes are first-class. Pick whichever matches the lifecycle
of the host using `claude_wrapper`.
## Long-lived chat-style sessions (DuplexSession)
config = ClaudeWrapper.Config.new(working_dir: ".")
{:ok, pid} = ClaudeWrapper.DuplexSession.start_link(config: config)
ClaudeWrapper.DuplexSession.subscribe(pid)
{:ok, result} = ClaudeWrapper.DuplexSession.send(pid, "Explain this codebase.")
# Subscriber inbox now holds streaming events:
# {:claude, {:assistant, msg}}
# {:claude, {:stream_event, msg}} -- partial token deltas
# {:claude, {:result, %ClaudeWrapper.Result{}}}
ClaudeWrapper.DuplexSession.close(pid)
See `ClaudeWrapper.DuplexSession` for the permission callback,
interrupt API, and full event vocabulary. `ClaudeWrapper.DuplexIEx`
wraps it with REPL helpers that stream tokens to stdout live.
## One-shot queries
# One-shot query (convenience -- uses default config)
{:ok, result} = ClaudeWrapper.query("Explain this error: ...")
# With options
{:ok, result} = ClaudeWrapper.query("Fix the bug in lib/foo.ex",
model: "sonnet",
working_dir: "/path/to/project",
max_turns: 5,
permission_mode: :bypass_permissions
)
# Streaming
ClaudeWrapper.stream("Implement the feature described in issue #42",
working_dir: "/path/to/project"
)
|> Stream.each(fn event -> IO.inspect(event.type) end)
|> Stream.run()
# Full control via Query builder
config = ClaudeWrapper.Config.new(working_dir: "/path/to/project")
ClaudeWrapper.Query.new("Fix the tests")
|> ClaudeWrapper.Query.model("sonnet")
|> ClaudeWrapper.Query.dangerously_skip_permissions()
|> ClaudeWrapper.Query.max_turns(10)
|> ClaudeWrapper.Query.execute(config)
## Multi-turn continuity for one-shot mode (Session)
`ClaudeWrapper.Session` threads `--resume <session_id>` across
one-shot calls so you can have a multi-turn conversation without
holding a subprocess open. Use it when you want a struct-passing
API rather than a process API, or when turns are far enough apart
that cold-start cost doesn't matter.
## Other modules
* `ClaudeWrapper.Query` -- the query builder
* `ClaudeWrapper.SessionServer` -- supervised wrapper for `Session`
* `ClaudeWrapper.McpConfig` -- programmatic `.mcp.json` builder
* `ClaudeWrapper.Retry` -- exponential backoff
* `ClaudeWrapper.Telemetry` -- `:telemetry` spans for exec/stream/session
* `ClaudeWrapper.Commands.{Auth, Mcp, Plugin, Marketplace, Doctor, Version}` -- CLI subcommand wrappers
## Binary discovery
The `claude` binary is found via (in order):
1. `:binary` option passed directly
2. `CLAUDE_CLI` environment variable
3. System PATH lookup
"""
alias ClaudeWrapper.{Commands, Config, Error, Query, Result}
@doc """
Run an arbitrary CLI command that isn't wrapped by a dedicated module.
This is the escape hatch for new or experimental CLI subcommands.
## Examples
ClaudeWrapper.raw(["config", "list"])
ClaudeWrapper.raw(["plugin", "install", "my-plugin"], working_dir: "/tmp")
"""
@spec raw([String.t()], keyword()) :: {:ok, String.t()} | {:error, term()}
def raw(args, opts \\ []) when is_list(args) do
config = Config.new(opts)
all_args = Config.base_args(config) ++ args
cmd_opts = Config.cmd_opts(config)
case System.cmd(config.binary, all_args, cmd_opts) do
{output, 0} -> {:ok, String.trim(output)}
{output, code} -> {:error, Error.command_failed(code, output)}
end
rescue
e in ErlangError -> {:error, Error.io(e)}
end
@doc """
Execute a one-shot query and return the result.
Convenience wrapper that builds a `Config` and `Query` from keyword options.
Returns `{:ok, %Result{}}` on success or `{:error, reason}` on failure.
## Options
Config options (passed to `ClaudeWrapper.Config.new/1`):
* `:binary` - Path to claude binary
* `:working_dir` - Working directory
* `:env` - Environment variables
* `:timeout` - Timeout in ms
* `:verbose` - Enable verbose output
* `:debug` - Enable debug output
Query options (passed to `Query` builder):
* `:model` - Model name
* `:system_prompt` - System prompt override
* `:max_turns` - Max turns
* `:max_budget_usd` - Budget limit
* `:permission_mode` - Permission mode atom
* `:dangerously_skip_permissions` - Bypass permissions (boolean)
* `:session_id` - Session ID
* `:continue_session` - Continue recent session (boolean)
* `:resume` - Resume session ID
"""
@spec query(String.t(), keyword()) :: {:ok, Result.t()} | {:error, term()}
def query(prompt, opts \\ []) do
{config_opts, query_opts} = split_opts(opts)
config = Config.new(config_opts)
query = build_query(prompt, query_opts)
Query.execute(query, config)
end
@doc """
Execute a query and return a lazy stream of `%StreamEvent{}` structs.
The subprocess starts when the stream is consumed. Accepts the same
options as `query/2`.
"""
@spec stream(String.t(), keyword()) :: Enumerable.t()
def stream(prompt, opts \\ []) do
{config_opts, query_opts} = split_opts(opts)
config = Config.new(config_opts)
query = build_query(prompt, query_opts)
Query.stream(query, config)
end
@doc """
Get the CLI version.
"""
@spec version(keyword()) :: {:ok, map()} | {:error, term()}
def version(opts \\ []) do
config = Config.new(opts)
Commands.Version.execute(config)
end
@doc """
Check authentication status.
"""
@spec auth_status(keyword()) :: {:ok, map()} | {:error, term()}
def auth_status(opts \\ []) do
config = Config.new(opts)
Commands.Auth.status(config)
end
@doc """
Run `claude doctor`.
"""
@spec doctor(keyword()) :: {:ok, String.t()} | {:error, term()}
def doctor(opts \\ []) do
config = Config.new(opts)
Commands.Doctor.execute(config)
end
@doc """
List configured agents.
Returns a list of agent maps with `:name` and `:model` keys.
## Options
* `:setting_sources` - comma-separated setting sources (e.g. `"user,project"`)
"""
@spec agents(keyword()) :: {:ok, [map()]} | {:error, term()}
def agents(opts \\ []) do
{config_opts, agent_opts} = split_opts(opts)
config = Config.new(config_opts)
Commands.Agents.list(config, agent_opts)
end
# --- Private ---
@config_keys [:binary, :working_dir, :env, :timeout, :verbose, :debug]
defp split_opts(opts) do
Enum.split_with(opts, fn {k, _v} -> k in @config_keys end)
end
defp build_query(prompt, opts) do
prompt
|> Query.new()
|> Query.apply_opts(opts)
end
end