Skip to content

About

Elixir wrapper for the Claude Code CLI

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

ClaudeWrapper

CI Hex.pm Docs License

Drive the Claude Code CLI from Elixir: typed results and errors, streaming, tool-permission callbacks, and long-lived sessions, over the same claude binary you already run in a terminal.

claude_wrapper owns exactly the seam between Elixir and the claude process: spawning it, framing its NDJSON, and turning its output into typed %ClaudeWrapper.Result{} / %ClaudeWrapper.Error{}. It takes no position on what you do with a result. It offers two ways to drive claude, and you pick by the lifecycle of your host:

  • One-shot (ClaudeWrapper.query/2, Query, Session): a fresh subprocess per turn, simple request/response. The fit for mix tasks, escripts, batch jobs, and anything that runs and exits.
  • Long-lived (ClaudeWrapper.DuplexSession): a GenServer holding one claude subprocess open across a whole conversation: streams partial tokens, interrupts mid-turn, and routes tool-permission prompts back to your code. The fit for chat UIs, agent runtimes, and Phoenix-backed interfaces.

The long-lived mode speaks the same duplex protocol the official @anthropic-ai/claude-agent-sdk uses internally and that the @agentclientprotocol/claude-agent-acp bridge relies on for IDE integrations like Zed's agent panel, so an OTP host can use claude the way an IDE backend does.

Native CLI compatibility

The emitted query/resident and command argv are checked against Claude Code 2.1.220 and 2.1.309 in pinned PR CI. Run mix claude.contract with CLAUDE_CONTRACT_BIN selecting an executable and optionally CLAUDE_CONTRACT_VERSION requiring its exact reported version. The task uses only native version/help and missing-operand parsing; its command construction uses a task-local fake executable and never the selected provider binary. Flags newer than a pinned version are checked from the version they are tagged with in the task, so an older pinned CLI does not report them as missing.

These are verified compatibility points, not a bisected minimum or a promise of full native semantics. Existing caller-selected CliVersion windows remain unchanged. Controlled fake binaries verify the checker can reject missing flags and commands.

Auth.setup_token/2 now rejects token-from-stdin setup with :unsupported_command before spawning. The CLI's top-level setup-token generates a credential; it cannot configure a supplied token through stdin.

Installation

def deps do
  [
    {:claude_wrapper, "~> 0.19.0"}
  ]
end

Requires the claude CLI installed and on your PATH (or set CLAUDE_CLI to its path). Run claude doctor (or, from Elixir, ClaudeWrapper.doctor/0) before your first real call.

Quick start

{:ok, result} = ClaudeWrapper.query("Explain this error: ...")
result.result       # the text
result.cost_usd     # the session's running total (a resumed session includes earlier turns)

Everything else is a variation on this: more control over the flags (Query), continuity across turns (Session / DuplexSession), streaming, or reading Claude Code's own on-disk state.

Driving claude

One-shot query and stream

For short-lived consumers, query/2 runs a fresh subprocess and returns the full result; stream/2 yields %StreamEvent{}s as they arrive. Both accept the same options (see Building the call).

{: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
  )

ClaudeWrapper.stream("Implement the feature in issue #42", working_dir: ".")
|> Stream.each(fn event -> IO.inspect(event.type) end)
|> Stream.run()

Multi-turn without a process (Session)

ClaudeWrapper.Session threads --resume <session_id> across one-shot calls, so you get multi-turn continuity without holding a subprocess open: a struct-passing API, ideal outside an OTP host or when turns are far apart in wall time.

session = ClaudeWrapper.Session.new(config, model: "sonnet")
{:ok, session, r1} = ClaudeWrapper.Session.send(session, "What files are here?")
{:ok, session, r2} = ClaudeWrapper.Session.send(session, "Add tests for lib/foo.ex")

ClaudeWrapper.SessionServer wraps this in a supervised GenServer when you want a process around the per-call flow but not live token streaming.

Long-lived sessions (DuplexSession)

Holds one claude subprocess open across many turns; subscribers see assistant messages, partial token deltas, and tool-call results live.

config = ClaudeWrapper.Config.new(working_dir: ".")
{:ok, pid} = ClaudeWrapper.DuplexSession.start_link(config: config)
:ok = ClaudeWrapper.DuplexSession.subscribe(pid)

# Resolves when the CLI emits its `result` event; the inbox streamed the turn.
{:ok, result} = ClaudeWrapper.DuplexSession.send(pid, "Explain this codebase.")

# Inbox: {:claude, {:system_init, id}} | {:assistant, _} | {:stream_event, _}
#        | {:user, _}  (tool results) | {:result, %ClaudeWrapper.Result{}}

ClaudeWrapper.DuplexSession.interrupt(pid)   # cancel an in-flight turn cleanly
ClaudeWrapper.DuplexSession.close(pid)        # end the session

Configure it with a Query. Pass a %Query{} for the session's spawn-time knobs (model, system prompt, permission mode, tool lists, mcp config, ...); its prompt and transport flags are ignored (the session owns stream-json and takes prompts per turn).

query = ClaudeWrapper.Query.new("") |> ClaudeWrapper.Query.model("sonnet") |> ClaudeWrapper.Query.allowed_tool("Read")
{:ok, pid} = ClaudeWrapper.DuplexSession.start_link(config: config, query: query)

Permission callback. When the CLI wants a tool, it routes the request through your :on_permission callback. Answer synchronously, or return :defer and answer later via respond_to_permission/3 (for human-in-the-loop UIs).

on_permission = fn tool_name, _input ->
  if tool_name == "Bash", do: {:deny, "no shell here"}, else: :allow
end

{:ok, pid} = ClaudeWrapper.DuplexSession.start_link(config: config, on_permission: on_permission)

Control requests. control/3 sends any stream-json control request and returns the CLI's response; :on_control_request answers inbound ones other than can_use_tool (defer with :defer and reply later through respond_to_control/3). Without a handler, inbound requests are answered with an error rather than left waiting.

{:ok, %{"model" => _}} = ClaudeWrapper.DuplexSession.control(pid, %{subtype: "set_model", model: "sonnet"})
{:ok, %{"mcpServers" => _}} = ClaudeWrapper.DuplexSession.control(pid, %{subtype: "mcp_status"})

Typed helpers cover the requests checked against the CLI: set_model/2, set_permission_mode/2, set_max_thinking_tokens/2, mcp_status/1, mcp_toggle/3, mcp_reconnect/2, mcp_set_servers/2, stop_task/2, rewind_files/3, get_settings/1 and apply_flag_settings/2.

In-process MCP tools. :sdk_mcp_servers serves MCP from inside the BEAM over the session's own stdio: no port, no subprocess, no config file. A server is any JSON-RPC handler function; DuplexSession.SdkMcp.tool_server/2 builds one from a list of tools. :initialize sends the protocol's initialize request (with hooks or other fields) and initialize_response/2 returns the CLI's answer.

alias ClaudeWrapper.DuplexSession.SdkMcp

lookup = %{
  name: "lookup_user",
  description: "Look up a user by email",
  input_schema: %{"type" => "object", "properties" => %{"email" => %{"type" => "string"}}},
  call: fn %{"email" => email} -> {:ok, MyApp.Users.describe(email)} end
}

{:ok, pid} =
  ClaudeWrapper.DuplexSession.start_link(
    config: config,
    sdk_mcp_servers: %{"app" => SdkMcp.tool_server("app", [lookup])},
    on_permission: fn _tool, _input -> :allow end
  )
# The model sees the tool as mcp__app__lookup_user.

Supervise it. Each session owns one Port; pair it with a DynamicSupervisor for per-conversation isolation, named registration, and OTP restart semantics. See ClaudeWrapper.DuplexSession for the full API and message vocabulary.

REPL helpers

For interactive exploration, two IEx helper modules stream tokens to stdout:

iex> import ClaudeWrapper.DuplexIEx      # one long-lived session in the process dictionary
iex> start(working_dir: ".")
iex> say("Explain the README briefly.")   # ...streams live...

iex> import ClaudeWrapper.IEx            # per-call one-shot mode
iex> chat("explain this codebase", working_dir: ".")
iex> cost()

Building the call

ClaudeWrapper.Query is the builder for the full CLI flag surface; Query.apply_opts/2 takes a keyword list of any setter, and query/2 / stream/2 / Session.send/3 all delegate to it, so the same options work everywhere. An unknown option name, a mis-shaped value (for example disallowed_tools: :bash), or conflicting session selectors (resume with continue_session, fork_session without either, session_id with either unless fork_session) raise ArgumentError before anything is spawned. Before 0.19 they were dropped silently.

One-shot and streaming queries on the built-in runners send the prompt to the CLI on stdin, so prompt size is limited by the CLI rather than by execve, and the prompt does not appear in the process table (prompt_input: :argv restores the old form; a custom runner still gets it in argv). An argument list over the platform limit returns {:error, %Error{kind: :argument_list_too_long}} before anything is spawned.

alias ClaudeWrapper.{Config, Query}

Query.new("Fix the tests")
|> Query.model("sonnet")
|> Query.max_turns(10)
|> Query.permission_mode(:bypass_permissions)
|> Query.allowed_tool("Read")
|> Query.execute(Config.new(working_dir: "/path/to/project"))

Print flags added for Claude Code 2.1.31x

Each has a setter and a matching apply_opts/2 key:

Setter / option Emits
restricted/1, restricted: true --restricted
permission_prompts/2, :host | :none --permission-prompts host|none
system_prompt_snapshot/2, :on | :off --system-prompt-snapshot on|off
autocompact/2, :auto or a positive integer token count --autocompact auto|<tokens>
allow_dangerously_skip_permissions/1 --allow-dangerously-skip-permissions
chrome/2, true | false --chrome / --no-chrome
forward_subagent_text/1 --forward-subagent-text
append_subagent_system_prompt/2 --append-subagent-system-prompt <text>
append_subagent_system_prompt_file/2 --append-subagent-system-prompt-file <path>
advisor/2 --advisor <value>
prompt_suggestions/2, true | false --prompt-suggestions / --prompt-suggestions false
permission_mode(:manual) --permission-mode manual
extra_args/2, a list of strings each string as one argv token
  • Unset means no flag. chrome and prompt_suggestions default to nil, which emits nothing and leaves the CLI default. prompt_suggestions(true) (or prompt_suggestions/1) keeps the bare flag; false emits --prompt-suggestions false. In apply_opts/2, false is passed through for these two, unlike the other boolean options.
  • Validation. An unknown permission_prompts, system_prompt_snapshot or permission_mode atom, and an autocompact value other than :auto or a positive integer, raise ArgumentError when the arguments are built. The CLI validates the token range itself (2.1.316 help gives 100k to 1M).
  • System-prompt snapshot. on reuses the system prompt recorded for the conversation until compaction (the CLI default, except under --bare outside cloud and background sessions); off renders it fresh. To change the system prompt of a resumed Session turn, pass the new prompt with system_prompt_snapshot: :off.
  • Forwarding subagent text needs print mode with --output-format stream-json. Query.stream/2 and a DuplexSession started with query: always use stream-json; execute/2 uses JSON output and the CLI may reject the flag there.
  • Bypass. allow_dangerously_skip_permissions/1 makes bypass a mode that can be switched to later; it does not turn bypass on. --dangerously-skip-permissions stays behind ClaudeWrapper.DangerousClient.
  • extra_args accepts only a list of strings, keeps their order and boundaries, and places them after every Query flag and before the -- prompt separator, so the prompt still goes on stdin or argv as configured. A "--" element is rejected. The arguments are not checked against the CLI.
  • :default permission mode still emits default for compatibility.

Version provenance: the issue (#328) reports these flags on Claude Code 2.1.305, 2.1.311 and 2.1.312. They were checked against the help output of an installed 2.1.316, and the three hidden value flags (--append-subagent-system-prompt, --append-subagent-system-prompt-file, --advisor) by their missing-operand errors on 2.1.316. mix claude.contract checks flag names only, not values or combinations.

Out of scope: these launch flags are not on Query (use extra_args/2 if needed): --ax-screen-reader, --bg/--background, --cloud, --desktop, --environment, --ide, --remote-control, --remote-control-session-name-prefix, --teleport.

Supporting builders: ClaudeWrapper.Prompt (composable prompts with deferred file/git expansion), ClaudeWrapper.McpConfig (programmatic .mcp.json), and ClaudeWrapper.ToolPattern (typed, validated tool specs).

ClaudeWrapper.McpConfig.new()
|> ClaudeWrapper.McpConfig.add_stdio("my-server", "npx", ["-y", "my-mcp-server"], env: %{"API_KEY" => "sk-..."})
|> ClaudeWrapper.McpConfig.write!(".mcp.json")

Operational concerns

Error handling

Every operational failure is {:error, %ClaudeWrapper.Error{}}, a raisable exception you match on by :kind, with details in :reason / :exit_code / :stdout / :stderr:

case ClaudeWrapper.query("...", max_turns: 1) do
  {:ok, result} -> result
  {:error, %ClaudeWrapper.Error{kind: :max_turns_exceeded}} -> :hit_limit
  {:error, %ClaudeWrapper.Error{kind: :api_error, reason: %{category: :rate_limit}}} -> :back_off
  {:error, %ClaudeWrapper.Error{kind: kind}} -> {:failed, kind}
end

A turn that ends on an API error (HTTP 429, 529, 5xx, a refused connection) is {:error, %Error{kind: :api_error}} with reason: %{status:, category:, num_turns:, session_id:, ...}; a 401 is :auth with reason :invalid_credentials. Before 0.19 these came back as {:ok, %Result{is_error: true}}.

For streaming calls, timeout: ms bounds both the total run and each gap between output frames. A stream that ends before its terminal result emits a stream_truncated error event. Without an explicit timeout, a per-frame idle safety deadline still applies. Select the Forcola runner below when timeout cleanup must terminate the CLI process group.

Each stdout line is bounded by the config's max_line_bytes (default 1 MiB); a longer line stops the CLI and ends the stream with a line_limit error event. With the Forcola runner the stream reads on demand, so a slow consumer holds the CLI back. The default Runner.Port cannot: an Erlang port has no read-side flow control, so a slow consumer's mailbox holds every line the CLI has written.

The CLI's own rail-stop caps are typed, recoverable errors, distinct from a genuine failure. :max_turns_exceeded (--max-turns) and :max_budget_exceeded (--max-budget-usd, separate from the client-side :budget_exceeded of ClaudeWrapper.Budget) each carry reason: %{cap:, cost_usd:, num_turns:, session_id:}, so a capped run can be resumed:

{:error, %ClaudeWrapper.Error{kind: :max_budget_exceeded, reason: %{session_id: sid}}} = ...
# resume with Query.resume(sid) / Session

Running from a mix release

When the VM runs from a mix release (RELEASE_ROOT is set), every spawn path removes the release's boot variables (RELEASE_*, including RELEASE_COOKIE, plus BINDIR and ROOTDIR) and its bin and erts-<vsn>/bin directories from claude's environment, so a project's own elixir or mix that the agent runs does not boot against the release (#310). Config.env is applied after the scrub, so a scrubbed name can still be set explicitly; Config.new(release_env: :inherit) turns the scrub off. ClaudeWrapper.ChildEnv.release_scrub/1 returns the entries for callers that build an environment themselves. Unsetting RELEASE_COOKIE is not an isolation boundary: the cookie is also in $RELEASE_ROOT/releases/COOKIE.

Leak-free execution (opt-in)

On the default Port paths, a timeout, halted stream, closed session, owner death or BEAM death stops claude itself: a small /bin/sh supervisor (one-shot and streaming) or a lifeline port (DuplexSession) sends SIGINT, then SIGTERM 2 s later, then SIGKILL 1 s after that (#303). A closed duplex session first gets 1 s to exit on its own stdin EOF. Only the claude pid is signalled, so processes it started (stdio MCP servers, tool commands) are stopped only if claude stops them, and a pid recycled in the moment between claude's exit and a signal is a residual risk. ClaudeWrapper.Config.exec/2, used by the Commands.* subcommand wrappers, still sends no signal on timeout. Add forcola and select its implementations to run every invocation under a process-group kill (SIGTERM then SIGKILL on timeout/halt/close/BEAM-death), which also reaches the CLI's descendants in its group:

# mix.exs:  {:forcola, "~> 0.7.0"}
config :claude_wrapper,
  runner: ClaudeWrapper.Runner.Forcola,                        # one-shot + streaming
  duplex_adapter: ClaudeWrapper.DuplexSession.Adapter.Forcola  # DuplexSession

Both are opt-in and additive (they compile only when forcola is present); POSIX-only. A single config can also select its own runner without touching application config: Config.new(runner: ClaudeWrapper.Runner.Forcola) (see Per-config runners). The duplex adapter uses Forcola's bounded pull delivery with separate stderr. The default Adapter.Port also keeps claude's stderr out of the VM's stderr (a bounded tail comes back from DuplexSession.shutdown/1 and the {:transport_terminal, _} event) and bounds each pending line (:max_line_bytes, default 1 MiB); :max_output_bytes is opt-in there. Set per-session bounds with adapter_opts: [max_line_bytes: ..., max_output_bytes: ...]; the defaults are 1 MiB per line and 64 MiB over the session. A bounded stderr tail is returned separately from provider NDJSON by DuplexSession.shutdown/1, alongside Forcola's terminal status, cleanup confirmation, and output evidence. For owner-death evidence, pass terminal_recipient: supervisor_pid in adapter_opts so an independent process receives Forcola's terminal record.

Observing a one-shot session identity

With Runner.Forcola, Query.execute/3 can announce the native session as soon as stdout reports system/init, while retaining synchronous Result/Error completion and the configured whole-run timeout:

reference = make_ref()
ClaudeWrapper.Query.execute(query, config,
  session_observer: {observer_pid, reference})

The local observer process handles {reference, %ClaudeWrapper.SessionObservation{session_id: session_id}} while the call runs. The execution caller sends this message before returning, so its later terminal reply to the same observer cannot overtake it.

Only the first valid, nonblank session ID is announced. Malformed, duplicate and conflicting later init events are ignored; stderr cannot supply an ID or terminal result. A dead observer is harmless and no caller callback runs. An observation is not proof of success: the call may still time out or fail. The caller owns persistence and must reject observations from stale attempts. The function waits for transport completion even after a result event.

Invalid observer options return :invalid_session_observer; a runner without observed execution returns :observation_unsupported, both before spawning. ClaudeWrapper.query/2 accepts the same :session_observer execution option alongside its existing config and query options. Calls without that option, and Query.execute/2, retain ordinary execution. Observed failure diagnostics keep stderr separate, with newline-normalized stdout; clean completion returns the usual parsed Result. Forcola's existing 24-hour bound applies when Config.timeout is nil.

Per-config runners and runner options

Config carries an optional :runner and :runner_options, so two configs for the same provider (different binary, working directory, endpoint, credentials, MCP files or CLAUDE_CONFIG_DIR) can run side by side. Each field falls back to config :claude_wrapper, runner: ... / runner_options: ... only when it is absent; an explicit runner_options: [] disables configured defaults. Nothing in the library writes application or OS environment state. ClaudeWrapper.query/2 accepts both as config options.

config =
  ClaudeWrapper.Config.new(
    binary: "/opt/claude-a/bin/claude",
    working_dir: "/srv/a",
    env: [{"CLAUDE_CONFIG_DIR", "/srv/a/.claude"}],
    timeout: 600_000,
    runner: ClaudeWrapper.Runner.Forcola,
    runner_options: [idle_timeout_ms: 120_000, max_output_bytes: 16_777_216]
  )

Runner options are honored only by evidenced one-shot execution (below). Paths that cannot honor them reject non-empty options before spawning: Command.run/3 and raw/2 return :invalid_runner_options, stream/2 yields one terminal runner_options_unsupported error event, and ClaudeWrapper.Config.exec/2 (used by the Commands.* wrappers) raises ArgumentError. DuplexSession.start_link/1 rejects non-empty resolved options before opening any adapter, returning :invalid_runner_options with reason {:unsupported_mode, :duplex}. Pass runner_options: [] on those calls to opt out of an application default.

Evidenced one-shot execution

Query.execute/3 with execution_observer: {pid, reference} (or any call whose config resolves non-empty runner options) runs through c:ClaudeWrapper.Runner.run_evidenced/5 and sends exactly one {reference, %ClaudeWrapper.ExecutionEvidence{}} before returning, on success or failure. A SessionObservation for the same call is sent earlier, as soon as the native ID appears. The record keeps separate facts: raw child exit status/signal, native termination, the bound that cancelled the run, output truncation, cleanup confirmation and its scope, guard close, retained partial stdout/stderr, the observed session ID, and the last terminal result found in retained stdout (kept even when the call fails). A terminal result, a session ID or a zero exit alone never means clean completion; missing or malformed native facts are :unknown. A process-group cleanup confirmation does not cover descendants that escaped the group.

Runner raises, throws and catchable exits return a typed :io error with reason: {:runner_failed, kind, reason} and still deliver one terminal record. This fallback retains the first native ID and up to 1 MiB of observed stdout with reconstructed newlines; unreported status, cleanup and stderr remain unknown or empty. Collector helper failures retain bounded received stdout and stderr. A collector observer failure returns :io with {:observer_failed, kind, reason} after draining the native run, preserving its terminal facts. Uncatchable execution caller death cannot deliver a record.

Runner.Forcola implements it around one Forcola.run/2 with an owned bounded collector and accepts :max_output_bytes (default 64 MiB), :idle_timeout_ms, :max_line_bytes, :env_mode, :interrupt_signal / :interrupt_grace_ms, :kill_grace_ms and :shim_path. It requires Forcola ~> 0.7.0 and a matching shim; an incompatible shim is rejected before SPAWN. An unsupported Forcola result contract returns :evidence_unsupported before spawning. Other runners are rejected with :evidence_unsupported before they are invoked; there is no fallback to the Port runner.

Async one-shot runs (Run)

ClaudeWrapper.Run.start/3 runs the same evidenced execution in its own process and returns a handle instead of blocking:

{:ok, run} = ClaudeWrapper.Run.start(query, config, events_to: self())

%{status: :running, session_id: sid, frames: n} = ClaudeWrapper.Run.snapshot(run)

case ClaudeWrapper.Run.await(run, 300_000) do
  {:ok, %ClaudeWrapper.Result{} = result, %ClaudeWrapper.ExecutionEvidence{}} -> result
  {:error, %ClaudeWrapper.Error{} = error, %ClaudeWrapper.ExecutionEvidence{}} -> error
  :timeout -> ClaudeWrapper.Run.cancel(run)
end
  • start/3 applies Query.execute/3's preflight (options, observers, runner support, runner options via c:ClaudeWrapper.Runner.check_evidenced/2, spawn target) and returns those rejections before anything is spawned.
  • Each parsed stdout event is sent to :events_to as {run.ref, {:frame, %StreamEvent{}}} in stream order, then {run.ref, {:done, status}}. Delivery is unacknowledged; when the recipient's queue holds :max_queued_frames (default 1,000) or more messages, frames are dropped and counted in snapshot/1.
  • snapshot/1 is answered from the run process's state and never waits on the child.
  • await/2 returns the outcome with the run's single evidence record; its timeout returns :timeout and leaves the run going.
  • cancel/2 is idempotent and returns {:error, %Error{kind: :cancelled}, evidence} with evidence.reason == :cancelled. With Runner.Forcola the run is stopped by closing its Forcola port, so the shim runs its owner-death sequence; Forcola 0.7 sends no report after that, so cleanup is :unknown.
  • If the process that called start/3 exits, the run is cancelled.

It needs a runner with run_evidenced/5 (currently Runner.Forcola).

Retry, telemetry, budget

  • ClaudeWrapper.Retry: exponential backoff around a Query. A retry re-sends the prompt as a new CLI process, and the tool effects of a failed attempt (file edits, commits, pushes, shell commands) are not undone and can run again. So the default retries only API errors that ended the run before any tool ran (:api_error with category :rate_limit, :overloaded, :server_error or :connection). Timeouts, non-zero exits and other failures are retried only when a caller :retry_on opts in; even then a timeout is retried only after the runner confirmed the attempt exited (Runner.Forcola), and a query with a pinned --session-id is never retried.

    ClaudeWrapper.Retry.execute(query, config, max_retries: 3, base_delay_ms: 1_000)
  • ClaudeWrapper.Telemetry: :telemetry.span/3-shaped events (:start / :stop / :exception) around every exec path, so one handler observes the whole lifecycle. :stop metadata carries :cost_usd (the session's running total), :exit_code, :duration; session and duplex turn stops add :turn_cost_usd.

    Event Emitted by
    [:claude_wrapper, :exec, _] query/2 / Query.execute/2
    [:claude_wrapper, :stream, _] stream/2 / Query.stream/2
    [:claude_wrapper, :session, :turn, _] Session.send/3
    [:claude_wrapper, :duplex, :session, _] DuplexSession process lifetime
    [:claude_wrapper, :duplex, :turn, _] DuplexSession.send/3
  • ClaudeWrapper.Budget: a client-side USD budget tracker for multi-turn loops.

Testing

Drive a DuplexSession against an in-process double (no network, no claude) with ClaudeWrapper.Test and ClaudeWrapper.DuplexSession.Adapter.Test.

Reading ~/.claude state

Beyond driving claude, the read-side modules introspect Claude Code's on-disk state, useful for dashboards, session pickers, and agent tooling. All parse liberally and return typed structs:

{:ok, history} = ClaudeWrapper.History.home()
{:ok, sessions} = ClaudeWrapper.History.sessions_for_path(history, File.cwd!())
{:ok, settings} = ClaudeWrapper.Settings.load(project_root: File.cwd!())

History, Settings, Agents, Skills, Jobs, and Worktrees cover session transcripts, the settings layers, agent-definition files, skills, background jobs, and git worktrees.

CLI subcommands & the raw escape hatch

Typed wrappers for the claude subcommands live under ClaudeWrapper.Commands.* (auth, mcp, plugin, marketplace, agents, doctor, version, …):

{:ok, plugins} = ClaudeWrapper.Commands.Plugin.list(config)
{:ok, _} = ClaudeWrapper.Commands.Marketplace.add(config, "https://github.com/org/marketplace")

For anything not yet wrapped, ClaudeWrapper.raw(["config", "list"]) runs an arbitrary subcommand through the configured runner.

Bundled binary (opt-in)

Instead of a PATH install, claude_wrapper can resolve, install, and version-floor a claude binary under its own priv/bin/:

config = ClaudeWrapper.Config.new(binary: :bundled)   # pure resolution; no network
mix claude_wrapper.install    # install/update the bundled binary
mix claude_wrapper.uninstall  # remove it
mix claude_wrapper.path       # print its path and install state

The PATH / CLAUDE_CLI default is unchanged for everyone else. See ClaudeWrapper.Bundled.

Module reference

Long-lived sessions (the headline feature)

Module Description
ClaudeWrapper.DuplexSession Long-lived stream-json session over a single claude subprocess
ClaudeWrapper.DuplexIEx REPL helpers for DuplexSession
ClaudeWrapper.Conversation Turn-history/cost bookkeeping over a DuplexSession

One-shot / per-call

Module Description
ClaudeWrapper Convenience API (query/2, stream/2, version/0, auth_status/0, doctor/0, raw/2)
ClaudeWrapper.Query Query builder + execute/stream
ClaudeWrapper.Run Async handle for one evidenced one-shot run: snapshot, await, cancel
ClaudeWrapper.Session Multi-turn continuity over per-call subprocesses
ClaudeWrapper.SessionServer Supervised wrapper for Session
ClaudeWrapper.IEx REPL helpers for one-shot/per-call mode

Prompt building & structured output

Module Description
ClaudeWrapper.Prompt Composable prompt builder with deferred file/git expansion
ClaudeWrapper.Stream Lazy Stream combinators over a DuplexSession turn
ClaudeWrapper.Structured Typed structured-output tasks; decode/2 for results already in hand (e.g. a DuplexSession turn)

Subprocess execution & transport

Module Description
ClaudeWrapper.Runner Behaviour selecting how one-shot/streaming subprocesses run
ClaudeWrapper.Runner.Port Default: System.cmd + /bin/sh streaming port
ClaudeWrapper.Runner.Forcola Opt-in leak-free runner (process-group kill); needs forcola
ClaudeWrapper.DuplexSession.Adapter Transport seam for DuplexSession
ClaudeWrapper.DuplexSession.Adapter.Port Default adapter: real claude subprocess over a Port
ClaudeWrapper.DuplexSession.Adapter.Forcola Opt-in leak-free adapter; needs forcola
ClaudeWrapper.DuplexSession.Adapter.Test Controllable in-process test double

Shared infrastructure

Module Description
ClaudeWrapper.Config Shared client config (binary, working_dir, env, timeout)
ClaudeWrapper.Result Parsed result struct
ClaudeWrapper.Error Canonical error exception (match on :kind)
ClaudeWrapper.ExecutionEvidence Terminal evidence record of an evidenced one-shot execution
ClaudeWrapper.StreamEvent NDJSON streaming event (partial_message/1)
ClaudeWrapper.McpConfig .mcp.json builder
ClaudeWrapper.Retry Exponential backoff retry
ClaudeWrapper.Telemetry :telemetry spans for exec/stream/session/duplex
ClaudeWrapper.Budget Client-side USD budget tracker
ClaudeWrapper.ToolPattern Typed, validated tool-spec builder
ClaudeWrapper.CliVersion Parse/compare the CLI version
ClaudeWrapper.DangerousClient Env-gated --dangerously-skip-permissions
ClaudeWrapper.Auth Env auth detection + failure classification
ClaudeWrapper.Test Drive a DuplexSession against an in-process double (network-free)
ClaudeWrapper.Bundled Opt-in bundled-binary resolution

Reading ~/.claude state

Module Description
ClaudeWrapper.History Session JSONL transcripts (projects/sessions/entries)
ClaudeWrapper.Settings The four settings.json layers
ClaudeWrapper.Agents Read/write agent definition files
ClaudeWrapper.Skills Read ~/.claude/skills
ClaudeWrapper.Jobs Read background-job state
ClaudeWrapper.Worktrees git worktree introspection

CLI subcommand wrappers (ClaudeWrapper.Commands.*)

Module Description
Commands.Auth Auth management (login modes, status, setup-token)
Commands.Agents List active background agent sessions (agents --json)
Commands.Mcp MCP server management (add/add-json/add-from-desktop/list/get/remove/serve)
Commands.Plugin Plugin install/enable/disable/update/validate/tag/details/prune
Commands.Marketplace Marketplace add/remove/list/update
Commands.AutoMode auto-mode config/defaults/critique
Commands.Ultrareview ultrareview (cloud multi-agent code review)
Commands.Install / Commands.Update claude install / claude update
Commands.Project claude project purge
Commands.Doctor / Commands.Version CLI health check / version

License

MIT. See the LICENSE file in the source repo for the full text.

About

Elixir wrapper for the Claude Code CLI

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages