Packages

Recursive LLM code execution engine for Elixir — give an LLM a stateful REPL

Current section

Files

Jump to
crucible_rlm lib crucible cli.ex
Raw

lib/crucible/cli.ex

defmodule Crucible.CLI do
@moduledoc """
CLI entrypoint for the Crucible library.
Intended for use in Burrito-wrapped releases (standalone binaries).
"""
alias Crucible.Logger, as: TrajectoryLogger
alias Crucible.Providers.Codex
alias Crucible.Skills
@type exit_code :: non_neg_integer()
@doc """
Run the CLI with argv, returning an exit code.
"""
@spec main([String.t()]) :: exit_code()
def main(argv) when is_list(argv) do
case argv do
["run" | rest] -> cmd_run(rest)
["skills" | rest] -> cmd_skills(rest)
["logs" | rest] -> cmd_logs(rest)
["providers" | _rest] -> cmd_providers()
["version" | _rest] -> cmd_version()
["--help"] -> usage()
["-h"] -> usage()
["help"] -> usage()
_ -> usage_error()
end
end
defp cmd_run(argv) do
{opts, args, invalid} =
OptionParser.parse(argv,
strict: [
input: :string,
provider: :string,
model: :string,
api_key: :string,
max_iterations: :integer,
temperature: :float,
max_tokens: :integer,
skills: :boolean,
skills_db: :string,
retry: :boolean,
no_log: :boolean,
json: :boolean,
quiet: :boolean
],
aliases: [
i: :input,
p: :provider,
m: :model,
q: :quiet
]
)
if invalid != [] do
IO.puts(:stderr, "Invalid options: #{inspect(invalid)}")
1
else
question =
case args do
[q] when is_binary(q) and q != "" -> q
_ -> nil
end
input_path = Keyword.get(opts, :input)
cond do
is_nil(question) ->
IO.puts(:stderr, "Missing required question argument.")
1
is_nil(input_path) or input_path == "" ->
IO.puts(:stderr, "Missing required --input/-i (file path or '-' for stdin).")
1
true ->
quiet? = Keyword.get(opts, :quiet, false) == true
json? = Keyword.get(opts, :json, false) == true
case read_input(input_path) do
{:error, msg} ->
IO.puts(:stderr, msg)
1
{:ok, input} ->
loop_opts =
[]
|> maybe_put_provider(Keyword.get(opts, :provider))
|> maybe_put(:model, Keyword.get(opts, :model))
|> maybe_put(:api_key, Keyword.get(opts, :api_key))
|> maybe_put(:max_iterations, Keyword.get(opts, :max_iterations))
|> maybe_put(:temperature, Keyword.get(opts, :temperature))
|> maybe_put(:max_tokens, Keyword.get(opts, :max_tokens))
|> maybe_put(:skills, Keyword.get(opts, :skills))
|> maybe_put(:skills_db_path, Keyword.get(opts, :skills_db))
|> maybe_put(:retry_with_backoff, Keyword.get(opts, :retry))
|> maybe_put(:log_trajectory, not (Keyword.get(opts, :no_log, false) == true))
|> Keyword.put(:return_meta, true)
|> maybe_put_on_iteration(quiet?)
case completion_fun().(question, input, loop_opts) do
{:ok, answer, meta} ->
print_run_result(answer, meta, json?, quiet?)
0
{:error, :max_iterations, meta} when is_map(meta) ->
IO.puts(:stderr, inspect(meta))
1
other ->
IO.puts(:stderr, "Unexpected result: #{inspect(other)}")
1
end
end
end
end
end
defp completion_fun do
case Application.get_env(:crucible, :completion_fun) do
fun when is_function(fun, 3) -> fun
_ -> &Crucible.completion/3
end
end
defp print_run_result(answer, meta, json?, quiet?) do
answer_str = stringify_answer(answer)
iterations = Map.get(meta, :iterations)
trajectory = Map.get(meta, :trajectory)
if json? do
IO.puts(
Jason.encode!(%{
answer: answer_str,
iterations: iterations,
trajectory: trajectory
})
)
else
if not quiet? and is_integer(iterations) do
IO.puts(:stderr, "iterations: #{iterations}")
end
IO.puts(answer_str)
if not quiet? and is_binary(trajectory) do
IO.puts(:stderr, "trajectory: #{trajectory}")
end
end
end
defp cmd_skills(argv) do
case argv do
["list" | rest] ->
{opts, _args, invalid} =
OptionParser.parse(rest,
strict: [skills_db: :string],
aliases: []
)
if invalid != [] do
IO.puts(:stderr, "Invalid options: #{inspect(invalid)}")
1
else
db = Keyword.get(opts, :skills_db)
case Skills.list(maybe_skills_db_opts(db)) do
{:ok, entries} ->
entries
|> Enum.each(fn e ->
inserted_at = format_unix(e.inserted_at)
preview = snippet_preview(e.snippet)
IO.puts("#{inserted_at}\t#{preview}\t#{e.question}")
end)
0
{:error, reason} ->
IO.puts(:stderr, "Failed to list skills: #{inspect(reason)}")
1
end
end
["clear" | rest] ->
{opts, _args, invalid} =
OptionParser.parse(rest,
strict: [skills_db: :string],
aliases: []
)
if invalid != [] do
IO.puts(:stderr, "Invalid options: #{inspect(invalid)}")
1
else
db = Keyword.get(opts, :skills_db)
case Skills.clear(maybe_skills_db_opts(db)) do
{:ok, deleted} ->
IO.puts("deleted: #{deleted}")
0
{:error, reason} ->
IO.puts(:stderr, "Failed to clear skills: #{inspect(reason)}")
1
end
end
["export" | rest] ->
{opts, _args, invalid} =
OptionParser.parse(rest,
strict: [skills_db: :string],
aliases: []
)
if invalid != [] do
IO.puts(:stderr, "Invalid options: #{inspect(invalid)}")
1
else
db = Keyword.get(opts, :skills_db)
case Skills.export(maybe_skills_db_opts(db)) do
{:ok, entries} ->
IO.puts(Jason.encode!(entries))
0
{:error, reason} ->
IO.puts(:stderr, "Failed to export skills: #{inspect(reason)}")
1
end
end
_ ->
IO.puts(:stderr, "Usage: crucible skills (list|clear|export) [--skills-db PATH]")
1
end
end
defp cmd_logs(argv) do
case argv do
["list" | _rest] ->
dir = "tmp/rlm_trajectories"
if File.dir?(dir) do
dir
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".jsonl"))
|> Enum.sort()
|> Enum.each(fn f -> IO.puts(Path.join(dir, f)) end)
end
0
["cleanup" | rest] ->
{opts, _args, invalid} =
OptionParser.parse(rest,
strict: [max_age: :integer],
aliases: []
)
if invalid != [] do
IO.puts(:stderr, "Invalid options: #{inspect(invalid)}")
1
else
days =
case Keyword.get(opts, :max_age) do
n when is_integer(n) and n >= 0 -> n
_ -> nil
end
if is_nil(days) do
IO.puts(:stderr, "Missing required --max-age DAYS (integer >= 0).")
1
else
case TrajectoryLogger.cleanup(max_age_days: days) do
{:ok, deleted} ->
IO.puts("deleted: #{deleted}")
0
other ->
IO.puts(:stderr, "Cleanup failed: #{inspect(other)}")
1
end
end
end
_ ->
IO.puts(:stderr, "Usage: crucible logs (list|cleanup --max-age DAYS)")
1
end
end
defp cmd_providers do
rows = [
provider_row("anthropic", System.get_env("ANTHROPIC_API_KEY"), "ANTHROPIC_API_KEY"),
provider_row("openai", System.get_env("OPENAI_API_KEY"), "OPENAI_API_KEY"),
provider_row("openrouter", System.get_env("OPENROUTER_API_KEY"), "OPENROUTER_API_KEY"),
provider_row("codex", Codex.resolve_codex_token(), Path.expand("~/.codex/auth.json"))
]
IO.puts("provider\tstatus\tsource")
Enum.each(rows, fn {p, status, source} -> IO.puts("#{p}\t#{status}\t#{source}") end)
0
end
defp cmd_version do
_ = Application.load(:crucible)
vsn = Application.spec(:crucible, :vsn) || "unknown"
IO.puts(to_string(vsn))
0
end
@usage_text """
Usage:
crucible run "question" --input file.txt [options]
crucible skills (list|clear|export)
crucible logs (list|cleanup --max-age DAYS)
crucible providers
crucible version
crucible --help
"""
defp usage do
IO.puts(@usage_text)
0
end
defp usage_error do
IO.puts(:stderr, @usage_text)
1
end
defp maybe_put(opts, _k, nil), do: opts
defp maybe_put(opts, _k, false), do: opts
defp maybe_put(opts, k, v), do: Keyword.put(opts, k, v)
defp maybe_put_provider(opts, nil), do: opts
defp maybe_put_provider(opts, provider_str) when is_binary(provider_str) do
provider =
case String.downcase(provider_str) do
"anthropic" -> :anthropic
"openai" -> :openai
"openrouter" -> :openrouter
"codex" -> :codex
other -> {:error, other}
end
case provider do
{:error, other} ->
IO.puts(
:stderr,
"Error: Invalid --provider: #{inspect(other)} (expected anthropic|openai|openrouter|codex)"
)
System.halt(1)
atom ->
Keyword.put(opts, :provider, atom)
end
end
defp maybe_put_on_iteration(opts, true), do: opts
defp maybe_put_on_iteration(opts, false) do
Keyword.put(opts, :on_iteration, fn iter ->
IO.write(:stderr, "iteration #{iter}\n")
end)
end
defp read_input("-") do
{:ok, IO.read(:stdio, :all) || ""}
end
defp read_input(path) when is_binary(path) do
case File.read(path) do
{:ok, s} -> {:ok, s}
{:error, reason} -> {:error, "Failed to read input #{inspect(path)}: #{inspect(reason)}"}
end
end
defp stringify_answer(answer) when is_binary(answer), do: answer
defp stringify_answer(answer), do: inspect(answer)
defp snippet_preview(snippet) when is_binary(snippet) do
snippet
|> String.replace("\n", "\\n")
|> String.slice(0, 80)
end
defp format_unix(unix) when is_integer(unix) do
case DateTime.from_unix(unix, :second) do
{:ok, dt} -> DateTime.to_iso8601(dt)
_ -> Integer.to_string(unix)
end
end
defp provider_row(provider, value, source) do
status = if is_binary(value) and value != "", do: "configured", else: "missing"
{provider, status, source}
end
defp maybe_skills_db_opts(nil), do: []
defp maybe_skills_db_opts(path), do: [skills_db_path: path]
end