defmodule Opal.Tool.Shell do @moduledoc """ Runs shell commands cross-platform with timeout support. The shell type is configurable per session via `context.shell`: - `:sh` — POSIX sh (default on Unix) - `:bash` — GNU Bash - `:zsh` — Zsh - `:cmd` — cmd.exe (default on Windows) - `:powershell` — PowerShell (cross-platform) The tool name and description exposed to the LLM change to match the configured shell, so the model generates appropriate commands. """ @behaviour Opal.Tool @default_timeout 30_000 @type shell :: :sh | :bash | :zsh | :cmd | :powershell @shell_meta %{ sh: %{ name: "shell", description: "Run a command in a POSIX shell (sh). Use standard Unix commands and syntax." }, bash: %{ name: "bash", description: "Run a command in Bash. Supports Bash-specific syntax like arrays, process substitution, and [[ ]]." }, zsh: %{ name: "zsh", description: "Run a command in Zsh. Supports Zsh globbing, extended syntax, and Bash-compatible commands." }, cmd: %{ name: "cmd", description: "Run a command in Windows cmd.exe. Use Windows commands (dir, type, findstr) and batch syntax." }, powershell: %{ name: "powershell", description: "Run a command in PowerShell. Use PowerShell cmdlets and syntax (Get-ChildItem, Select-String, foreach, |)." } } # Returns {executable, args_list} for the given shell and command. defp shell_cmd(:sh, command), do: {"sh", ["-c", command]} defp shell_cmd(:bash, command), do: {"bash", ["-c", command]} defp shell_cmd(:zsh, command), do: {"zsh", ["-c", command]} defp shell_cmd(:cmd, command), do: {"cmd", ["/C", command]} defp shell_cmd(:powershell, command), do: {"powershell", ["-NoProfile", "-NonInteractive", "-Command", command]} # -- Behaviour callbacks (zero-arity use platform default) -- @impl true @spec name() :: String.t() def name, do: name(default_shell()) @impl true @spec description() :: String.t() def description, do: description(default_shell()) @impl true def meta(%{"command" => command}) do truncated = if String.length(command) > 60, do: String.slice(command, 0, 57) <> "...", else: command "Run `#{truncated}`" end def meta(_), do: "Run command" @impl true @spec parameters() :: map() def parameters do %{ "type" => "object", "properties" => %{ "command" => %{"type" => "string", "description" => "The shell command to execute"}, "timeout" => %{ "type" => "integer", "description" => "Timeout in milliseconds (default: 30000)" } }, "required" => ["command"] } end @impl true @spec execute(map(), map()) :: {:ok, String.t()} | {:error, String.t()} def execute(%{"command" => command} = args, %{working_dir: working_dir} = context) do timeout = Map.get(args, "timeout", @default_timeout) shell_type = case context do %{config: %{shell: shell}} when shell != nil -> shell _ -> default_shell() end {executable, shell_args} = shell_cmd(shell_type, command) opts = [stderr_to_stdout: true, cd: working_dir] emit = Map.get(context, :emit) run_command(executable, shell_args, opts, timeout, emit) end def execute(%{"command" => _}, _context), do: {:error, "Missing working_dir in context"} def execute(_args, _context), do: {:error, "Missing required parameter: command"} # -- Config-aware variants (called by the agent with session config) -- @doc "Returns the tool name for the given shell type." @spec name(shell()) :: String.t() def name(shell_type), do: Map.fetch!(@shell_meta, shell_type).name @doc "Returns the tool description for the given shell type." @spec description(shell()) :: String.t() def description(shell_type), do: Map.fetch!(@shell_meta, shell_type).description # -- Internals -- @doc "Returns the default shell for the current platform." @spec default_shell() :: shell() def default_shell do case :os.type() do {:unix, _} -> :sh {:win32, _} -> :cmd end end defp run_command(shell, args, opts, timeout, emit) do task = Task.async(fn -> if emit do run_streaming(shell, args, opts, emit) else System.cmd(shell, args, opts) end end) case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do {:ok, {output, 0}} -> {:ok, output} {:ok, {output, exit_code}} -> {:error, "Command exited with status #{exit_code}\n#{output}"} nil -> {:error, "Command timed out after #{timeout}ms"} end end # Runs a command via Port, emitting output chunks as they arrive. defp run_streaming(shell, args, opts, emit) do port_opts = [ :binary, :exit_status, :use_stdio, :stderr_to_stdout, {:args, args} ] port_opts = case Keyword.get(opts, :cd) do nil -> port_opts dir -> [{:cd, String.to_charlist(dir)} | port_opts] end port = Port.open({:spawn_executable, System.find_executable(shell)}, port_opts) collect_port_output(port, emit, []) end defp collect_port_output(port, emit, acc) do receive do {^port, {:data, data}} -> emit.(data) collect_port_output(port, emit, [data | acc]) {^port, {:exit_status, status}} -> output = acc |> Enum.reverse() |> IO.iodata_to_binary() {output, status} end end end