defmodule Omni.Tools.Bash do
@moduledoc """
An `Omni.Tool` for executing shell commands.
Runs commands in a configured shell (bash by default, falling back to sh)
with a working directory, optional environment variables, and timeout.
tool = Omni.Tools.Bash.new(dir: "/path/to/workspace")
tool = Omni.Tools.Bash.new(dir: "/app", timeout: 60_000, env: [{"NODE_ENV", "test"}])
The tool delegates execution to `Omni.Tools.Bash.Runner`, which can also
be used independently of the tool machinery.
## Options
- `:dir` (required) — working directory. Must exist at init time.
- `:env` — extra environment variables as `[{String.t(), String.t()}]`.
Merged additively with the inherited environment. Default `[]`.
- `:timeout` — execution timeout in milliseconds. Default `30_000`.
- `:max_output` — output truncation limit in bytes. Tail-biased, snapped to
line boundaries. Default `50_000`.
- `:shell` — explicit shell as `{executable, args}` tuple.
Default: auto-resolved (bash then sh fallback).
- `:command_prefix` — string prepended to every command. Default `nil`.
"""
use Omni.Tool, name: "bash"
alias Omni.Tools.Bash.Runner
@defaults [
env: [],
timeout: 30_000,
max_output: 50_000,
command_prefix: nil
]
@impl Omni.Tool
def init(opts) do
opts =
@defaults
|> Keyword.merge(Application.get_env(:omni_tools, __MODULE__, []))
|> Keyword.merge(opts || [])
dir = Keyword.get(opts, :dir) || raise ArgumentError, "missing required :dir option"
unless File.dir?(dir) do
raise ArgumentError, ":dir does not exist or is not a directory: #{inspect(dir)}"
end
unless Path.type(dir) == :absolute do
raise ArgumentError, ":dir must be an absolute path, got: #{inspect(dir)}"
end
[
dir: Path.expand(dir),
shell: Runner.resolve_shell(opts),
env: validate_env!(Keyword.fetch!(opts, :env)),
timeout: Keyword.fetch!(opts, :timeout),
max_output: Keyword.fetch!(opts, :max_output),
command_prefix: Keyword.fetch!(opts, :command_prefix)
]
end
@impl Omni.Tool
def schema(_state) do
import Omni.Schema
object(
%{
title:
string(
description:
"Brief title describing the command's purpose in active voice, e.g. 'List project files'"
),
command: string(description: "Shell command to execute")
},
required: [:title, :command]
)
end
@impl Omni.Tool
def description(state) do
{shell_exe, _args} = Keyword.fetch!(state, :shell)
shell_name = Path.basename(shell_exe)
dir = Keyword.fetch!(state, :dir)
max_output = Keyword.fetch!(state, :max_output)
"""
Execute shell commands in a #{shell_name} shell.
## Environment
- Working directory: `#{dir}`
- Shell: `#{shell_exe}`
## Output
- stdout and stderr are merged into a single output stream
- Output is truncated to the last #{max_output / 1024}KB
"""
end
@impl Omni.Tool
def call(%{command: command}, state) do
shell = Keyword.fetch!(state, :shell)
runner_opts = Keyword.take(state, [:dir, :env, :timeout, :max_output, :command_prefix])
case Runner.run(command, shell, runner_opts) do
{:ok, %{output: ""}} ->
"(no output)"
{:ok, %{output: output}} ->
output
{:error, :nonzero, %{output: output, exit_code: code}} ->
raise format_error(output, "Command exited with status #{code}")
{:error, :timeout, %{output: output}} ->
raise format_error(output, "Command timed out")
end
end
defp format_error("", message), do: message
defp format_error(output, message), do: "#{output}\n#{message}"
defp validate_env!(env) do
Enum.each(env, fn
{k, v} when is_binary(k) and is_binary(v) ->
:ok
other ->
raise ArgumentError,
"invalid :env entry: expected {String.t(), String.t()}, got: #{inspect(other)}"
end)
env
end
end