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.haltin your handler (orOptimus.parse!, which halts). It runs inside your server, so halting takes the whole server down. Return an integer exit code instead, and useContext.warn/2for stderr; writing to the:stderrdevice 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.