Getting Started

Add Terminalwire to a Phoenix or Plug app and build your first terminal

Terminalwire streams a command-line app straight from your Phoenix or Plug server to your users’ machines over a single WebSocket. You write your CLI in your app, calling your contexts, Ecto, and business logic directly, and it runs on the user’s workstation with access to their terminal, files, and browser. There’s no API to build and no separate client to ship.

Install

Add Terminalwire and a WebSocket adapter to your mix.exs:

def deps do
  [
    {:terminalwire, "~> 0.1"},
    {:websock_adapter, "~> 0.5"}   # upgrades a Plug/Phoenix conn to a socket
  ]
end

Then fetch:

mix deps.get

websock_adapter is a separate dependency because Terminalwire only depends on the abstract WebSock interface, the common one spoken by Phoenix, Bandit, and Plug.Cowboy, so the same handler wires into all of them.

Define your CLI

Define commands as functions with Terminalwire.CLI. Public functions are commands, their parameters are the command’s arguments, and @desc is the help text β€” like Ruby’s Thor.

defmodule MyApp.CLI do
  use Terminalwire.CLI, name: "my-app"

  @desc "Greet someone by name"
  def hello(name) do
    puts("Hello, #{name}!")
  end

  @desc "Deploy to an environment"
  def deploy(env) do
    if String.trim(gets("Deploy to #{env}? [y/N] ")) == "y" do
      puts("Deploying #{env}…")
    else
      puts("Aborted")
    end
  end
end

Now my-app hello Ada runs hello("Ada"), my-app deploy staging runs deploy("staging"), and my-app (or my-app help) prints a generated command list. Inside a command, puts/print/warn/gets/read_secret/env talk to the user’s terminal, and context/0 reaches files, the browser, and the rest.

Terminalwire.CLI is a thin layer over a plain handler β€” a run(ctx) function taking a Terminalwire.Server.Context. Drop down to it when you want full control of parsing (flags, subcommands) with a library like Optimus; both use the same Context.

Either way, your code runs in its own BEAM process whose group leader is a Terminalwire IO device, so IO.puts, IO.ANSI colors, and any library that writes to standard IO (like Owl) stream straight to the user’s terminal. The Context covers everything that isn’t standard IO: args, prompts, the client’s terminal, files, env, and the browser.

Never call System.halt in your handler (or Optimus.parse!, which halts). It runs inside your server, so halting takes the whole server down. Return an integer exit code instead, and use Context.warn/2 for stderr; writing to the :stderr device goes to the server’s console, not the client’s.

The Context API

| | | |—|—| | args | Context.args(ctx) β€” the argv list you parse | | stdout | Context.puts/print β€” or just IO.puts / Owl.IO.puts | | stderr | Context.warn(ctx, msg) | | input | Context.gets(ctx, prompt), Context.read_secret(ctx, prompt) | | piped stdin | Context.read(ctx), Context.read_chunk(ctx, n) | | files | Context.file_read/file_write/file_append/file_delete/file_exists? | | directories | Context.dir_list/dir_create/dir_delete/dir_exists? | | env vars | Context.env(ctx, "NAME") | | browser | Context.browser_launch(ctx, url) | | terminal | Context.terminal(ctx) β€” size, color, TTY flags | | raw input | Context.raw_input(ctx, mode, fn reader -> ... end), Context.read_key(ctx, mode) |

Files, env, and the browser are gated by the client’s entitlements: your server requests access and the client enforces a per-app policy. The server never touches the user’s machine on its own.

Mount the endpoint

Upgrade your /terminal route to the ready-made Terminalwire.WebSock handler, passing your CLI as the :handler.

Phoenix

# lib/my_app_web/controllers/terminal_controller.ex
def show(conn, _params) do
  WebSockAdapter.upgrade(conn, Terminalwire.WebSock,
    [handler: &MyApp.CLI.run/1], timeout: :infinity)
end
# lib/my_app_web/router.ex
get "/terminal", TerminalController, :show

Plug / Bandit

defmodule MyApp.Router do
  use Plug.Router
  plug :match
  plug :dispatch

  get "/terminal" do
    WebSockAdapter.upgrade(conn, Terminalwire.WebSock,
      [handler: &MyApp.CLI.run/1], timeout: :infinity)
  end

  match _, do: send_resp(conn, 404, "not found")
end

Use timeout: :infinity so a long-running command doesn’t get its socket closed.

Connect a client

In development, point a launcher stub at your server and run it like any other CLI. Create the file, make it executable, and go:

printf '#!/usr/bin/env terminalwire-exec\nurl: "ws://localhost:4000/terminal"\n' > my-app
chmod +x my-app
./my-app hello Ada
Hello, Ada!

Phoenix’s default development port is 4000; a standalone Bandit example might use 8080β€”match your server. If you don’t have the Terminalwire client installed yet, see the Client installation guide.

Feature parity

The Elixir server speaks the same wire protocol as the Ruby server and the Go client, verified by a shared conformance corpus. It’s at parity with Rails: streaming stdout/stderr with flow control, live window resize, Ctrl-C interrupts (exit status 130), piped stdin (cat data.csv | my-app import), TTY and color detection, raw single-key input for REPLs and TUIs, files, env vars, browser launch, and per-app entitlements.