# Elixir

Stream a CLI straight from your Phoenix or Plug app, no API required

Terminalwire streams a command-line app straight from your Phoenix or Plug server to your users over a single WebSocket. You write the CLI inside your app, calling your contexts, Ecto, and business logic directly, with no API to build and no client to ship. Each connection is a supervised process and your CLI handler runs in its own task, so it sits on the BEAM like any other workload. It's [open source](https://github.com/terminalwire/elixir), published on [Hex](https://hex.pm/packages/terminalwire), with [docs on HexDocs](https://hexdocs.pm/terminalwire).

Each chapter below walks you through adding Terminalwire to an Elixir app and building command-line apps for your users.


## Getting Started

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`:

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

Then fetch:

```sh
$ mix deps.get
```

`websock_adapter` is a separate dependency because Terminalwire only depends on the abstract [`WebSock`](https://hex.pm/packages/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.

```elixir
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](https://hexdocs.pm/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](https://hexdocs.pm/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

```elixir
# 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
```

```elixir
# lib/my_app_web/router.ex
get "/terminal", TerminalController, :show
```

### Plug / Bandit

```elixir
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:

```sh
$ 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`, so match your server. If you don't have the Terminalwire client installed yet, see the [Client installation](../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.


## Defining Commands

`Terminalwire.CLI` turns a module into a command router. Public functions become commands, their parameters become arguments, and `@desc` becomes the help text. It covers the common case (named commands with positional arguments) without a parser library. When you need flags or subcommands, drop down to a plain handler.

## Commands

A public function is a command. Its name is what the user types.

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

  @desc "Say hello"
  def hello do
    puts("Hello, World!")
  end
end
```

```sh
$ my-app hello
Hello, World!
```

Running `my-app` with no command (or `my-app help`) prints the generated list of commands and their `@desc` text.

## Arguments

Function parameters are positional arguments, parsed left to right.

```elixir
@desc "Greet NAME with GREETING"
def greet(greeting, name) do
  puts("#{greeting}, #{name}!")
end
```

```sh
$ my-app greet "Good morning" Ada
Good morning, Ada!
```

A default value makes an argument optional.

```elixir
@desc "Say hello to NAME, or the world"
def hello(name \\ "World") do
  puts("Hello, #{name}!")
end
```

```sh
$ my-app hello
Hello, World!
$ my-app hello Ada
Hello, Ada!
```

## Flags and subcommands

`Terminalwire.CLI` deliberately stops at positional arguments. For `--flags`, `-o` options, or nested subcommands, write a handler (a `run/1` function that takes a `Terminalwire.Server.Context`) and parse the args yourself. [Optimus](https://hexdocs.pm/optimus) is a good fit.

```elixir
defmodule MyApp.Deploy do
  alias Terminalwire.Server.Context

  @optimus Optimus.new!(
    name: "deploy",
    args: [env: [required: true]],
    flags: [verbose: [short: "-v", long: "--verbose"]]
  )

  def run(ctx) do
    # Don't use Optimus.parse!/2 — it calls System.halt on error, which would
    # take your server down. Parse the result and return an exit code instead.
    case Optimus.parse(@optimus, Context.args(ctx)) do
      {:ok, parsed} ->
        Context.puts(ctx, "Deploying #{parsed.args.env}…")
        if parsed.flags.verbose, do: Context.puts(ctx, "(verbose)")
        0

      {:error, message} ->
        Context.warn(ctx, message)
        1
    end
  end
end
```

Mount a handler the same way you mount a `Terminalwire.CLI` module: pass `&MyApp.Deploy.run/1` as the `:handler`. Both forms receive the same `Context`, so you can mix them: a `Terminalwire.CLI` router for everyday commands, a handler for the one command that needs rich parsing.

> **Never call `System.halt`** (or anything that does, like `Optimus.parse!`). Your command runs inside your server process; halting takes the whole server down. Return an integer exit code instead, or call `Context.exit(ctx, status)`.

Next: [Standard I/O](./stdio) covers reading and writing the terminal in more detail.


## Sessions

Each command runs and exits, so anything you want to remember between commands (who's logged in, a chosen project) has to live on the client. Terminalwire gives every server a small storage area on the user's workstation, the same place a browser keeps cookies. You write to it with the [file API](./files).

Unlike Rails, the Elixir server doesn't ship a `session` helper. You don't need much: sign a value so the user can't forge it, write it to a file, read it back. `Phoenix.Token` does the signing with your endpoint's secret.

## A session, in three functions

```elixir
defmodule MyApp.CLI do
  use Terminalwire.CLI, name: "my-app"
  alias Terminalwire.Server.Context

  @session "session"
  @salt "user auth"
  @max_age 60 * 60 * 24 * 30  # 30 days

  defp put_current_user(user) do
    token = Phoenix.Token.sign(MyAppWeb.Endpoint, @salt, user.id)
    Context.file_write(context(), @session, token)
  end

  defp current_user do
    ctx = context()

    with true <- Context.file_exists?(ctx, @session),
         token <- Context.file_read(ctx, @session),
         {:ok, id} <- Phoenix.Token.verify(MyAppWeb.Endpoint, @salt, token, max_age: @max_age) do
      MyApp.Accounts.get_user(id)
    else
      _ -> nil
    end
  end

  defp log_out, do: Context.file_delete(context(), @session)
end
```

`put_current_user/1` signs the user's id and writes it; `current_user/0` reads the file and verifies the signature, returning `nil` if it's missing, tampered with, or past `max_age`; `log_out/0` deletes the file. Because the token is signed with `secret_key_base`, the user can read the file but can't change who they are.

## Use it in commands

```elixir
@desc "Show who you're logged in as"
def whoami do
  case current_user() do
    nil -> puts("Not logged in. Run: my-app login")
    user -> puts("Logged in as #{user.email}")
  end
end
```

[Authentication](./authentication) builds `login` on top of these helpers, with both a password prompt and a browser flow.

## Where it lives

The file sits in your server's storage directory on the client, alongside any other state you write. It's scoped to your server's origin, so one app's session can't be read by another. Keep what you store small; every read and write is a round trip to the workstation.


## Authentication

Logging in means proving who the user is, then writing a [session](./sessions) so the next command remembers them. Two flows cover most apps: a password prompt, and a browser handoff for OAuth or SSO. Both end the same way: calling `put_current_user/1`.

## Password

Read the email out loud and the password hidden, then check them against whatever your app already uses to authenticate.

```elixir
@desc "Log in with your email and password"
def login do
  email = gets("Email: ") |> String.trim()
  password = read_secret("Password: ")

  case MyApp.Accounts.authenticate(email, password) do
    {:ok, user} ->
      put_current_user(user)
      puts("Logged in as #{user.email}")

    :error ->
      warn("Invalid email or password")
  end
end
```

`read_secret/1` keeps the password off the screen. `MyApp.Accounts.authenticate/2` is your code, the same function your web login calls.

## Browser

When login goes through OAuth, SSO, or a provider you don't control, send the user to the browser and wait for your web app to hand a result back. The command runs in its own process, so it can sit in a `receive` while the user signs in.

Mint a one-time nonce, subscribe to a topic keyed on it, and open an authorize page:

```elixir
@desc "Log in through your browser"
def login do
  nonce = Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false)
  token = Phoenix.Token.sign(MyAppWeb.Endpoint, "cli login", nonce)
  Phoenix.PubSub.subscribe(MyApp.PubSub, "cli_login:#{nonce}")

  url = url(~p"/cli/login?#{[token: token]}")
  puts("Opening #{url}")
  Terminalwire.Server.Context.browser_launch(context(), url)

  receive do
    {:cli_login, user_id} ->
      put_current_user(MyApp.Accounts.get_user!(user_id))
      puts("Logged in")
  after
    :timer.minutes(5) -> warn("Login timed out. Try again.")
  end
end
```

The page is an ordinary authenticated route in your Phoenix app. The user signs in the usual way, approves the request, and the controller broadcasts their id back to the waiting command:

```elixir
defmodule MyAppWeb.CliLoginController do
  use MyAppWeb, :controller

  plug :require_authenticated_user

  # GET /cli/login?token=… — show an "Authorize this terminal app?" page.
  def new(conn, %{"token" => token}) do
    render(conn, :new, token: token)
  end

  # POST /cli/login — the user approved; signal the command and confirm.
  def create(conn, %{"token" => token}) do
    case Phoenix.Token.verify(MyAppWeb.Endpoint, "cli login", token, max_age: 600) do
      {:ok, nonce} ->
        Phoenix.PubSub.broadcast(
          MyApp.PubSub,
          "cli_login:#{nonce}",
          {:cli_login, conn.assigns.current_user.id}
        )
        render(conn, :done)

      {:error, _} ->
        conn |> put_flash(:error, "This login link expired.") |> redirect(to: ~p"/")
    end
  end
end
```

The nonce ties the browser session to the command that started it, and the token expires in ten minutes. Nothing about the user crosses the wire except an id you signed, broadcast over PubSub to the process that's waiting for it.


## Standard I/O

A command's standard I/O (stdout, stderr, stdin) is wired to the user's terminal across the WebSocket. There are two ways to reach it: the helpers imported into a `Terminalwire.CLI` module, and plain `IO`.

## Output

Inside a command, `puts`, `print`, and `warn` write to the user's terminal.

```elixir
puts("Hello, World!")   # writes a line to stdout
print("Working… ")      # no trailing newline
warn("disk almost full") # writes to stderr
```

Your command process runs with a Terminalwire IO device as its **group leader**, so the standard `IO` functions reach the same terminal, including ANSI color and anything built on top of it.

```elixir
IO.puts(IO.ANSI.format([:green, "✓ done"]))
Owl.IO.puts(["deploying ", Owl.Data.tag("staging", :cyan)])
```

That's why a library like [Owl](https://hexdocs.pm/owl) works unchanged: it writes to standard IO, and standard IO is the user's terminal.

> Write errors with `warn` (or `Context.warn/2`). Don't write to the `:stderr` device directly; that goes to your *server's* console, not the client's.

## Input

`gets` reads a line. The prompt is optional.

```elixir
name = gets("What is your name? ") |> String.trim()
puts("Hello, #{name}!")
```

`read_secret` reads a line without echoing it. Use it for passwords and tokens so they don't appear on screen.

```elixir
password = read_secret("Password: ")
```

## Piped input

When a user pipes data in (`cat data.csv | my-app import`), read it through the context rather than `gets`. `Context.read/1` drains stdin to EOF; `Context.read_chunk/2` pulls one chunk at a time for streaming.

```elixir
@desc "Import rows from piped CSV"
def import do
  context()
  |> Terminalwire.Server.Context.read()
  |> String.split("\n", trim: true)
  |> Enum.each(&MyApp.Imports.process/1)
end
```

Single-keypress and raw terminal input (for prompts, pagers, and TUIs) are covered by `Context.read_key/2` and `Context.raw_input/3`.


## Files & Directories

Your server can read and write files on the user's workstation through the context. Every call crosses the WebSocket to the client, which enforces what your server is allowed to touch.

## Entitlements

By default a server may only read and write its own storage directory on the client: the per-server area Terminalwire also uses for session state, the same idea as a browser's cookie jar. Every other path is denied until the user grants it with `terminalwire-exec policy`. The server asks; the client decides. It never reaches outside what the user has allowed.

## Files

```elixir
alias Terminalwire.Server.Context

def export(ctx) do
  Context.file_write(ctx, "report.csv", MyApp.Reports.to_csv())
  Context.puts(ctx, "Wrote report.csv")
end
```

The file functions mirror what you'd expect:

| | |
|---|---|
| `Context.file_read(ctx, path)` | read the contents |
| `Context.file_write(ctx, path, content)` | write (replacing) |
| `Context.file_append(ctx, path, content)` | append |
| `Context.file_delete(ctx, path)` | delete |
| `Context.file_exists?(ctx, path)` | test for existence |

## Directories

```elixir
Context.dir_create(ctx, "exports")
Context.dir_list(ctx, "exports")    # => ["report.csv", …]
Context.dir_exists?(ctx, "exports")
Context.dir_delete(ctx, "exports")
```

A write to a path the user hasn't granted fails rather than silently succeeding. Catch it and point them at the grant command instead of leaving a cryptic stack trace:

```elixir
try do
  Context.file_write(ctx, path, data)
  Context.puts(ctx, "Saved #{path}")
rescue
  _ -> Context.warn(ctx, "Can't write #{path}. Grant access with: terminalwire-exec policy")
end
```


## Environment Variables

`System.get_env/1` on your server reads the *server's* environment. To read a variable from the user's workstation, ask the context.

```elixir
@desc "Show the active AWS profile on your machine"
def whoami do
  case env("AWS_PROFILE") do
    nil -> puts("No AWS_PROFILE set")
    profile -> puts("Using #{profile}")
  end
end
```

In a plain handler the same value is `Context.env(ctx, "AWS_PROFILE")`.

## Entitlements

Like the file system, the client's environment is gated. By default the server can't read it, and the user grants individual variables with `terminalwire-exec policy`. Ask for the few you need; don't expect the whole environment.


## Web Browser

A command can open a web page on the user's machine. It's how you hand off to anything that's easier in a browser than a terminal: OAuth and SSO login, a billing page, a generated report.

```elixir
@desc "Open your dashboard"
def dashboard do
  Terminalwire.Server.Context.browser_launch(context(), "https://example.com/dashboard")
end
```

In a handler, you already have the context:

```elixir
def run(ctx) do
  Terminalwire.Server.Context.browser_launch(ctx, url)
end
```

Pair it with a Phoenix route to build a real login flow: open an authorize page, let the user sign in with whatever your web app already uses, and hand a token back to the waiting command. [Authentication](./authentication) walks through it end to end.

## Security

`browser_launch` only opens `http` and `https` URLs. Schemes like `file:` are refused, so a server can't use the browser to reach into the workstation.


## Distribution & Licensing

Once your CLI works in development, two things get it onto your users' machines: a license for the server, and an install command for the client.

## Get a license

Terminalwire Server needs a license to run in production. Request one from your [Terminalwire account](/developer/licenses) with the URL of your app, or from the command line:

```sh
# Core — free for personal use and small businesses
$ terminalwire license request https://example.com/terminal --product core

# Pro — for commercial use
$ terminalwire license request https://example.com/terminal --product pro
```

Core is free for personal and non-commercial use. Pro ($1,299/year per site, up to 1,000 client users) covers commercial use, with no revenue reporting and nobody looking at your books. Requesting a paid license from the command line opens the browser to collect payment. Without a valid license, users see a warning when they run your app, so get one before you distribute.

## Install with one line

The simplest way for users to install your app is a one-line script. It installs the Terminalwire client, wires up their shell, and installs your app:

```sh
curl -sSL https://my-app.terminalwire.sh | bash
```

Replace `my-app` with your app's binary name.

## List it in the directory

Register a distribution so users can install by name and find your app in the [directory](/applications):

```sh
$ terminalwire distribution create my-app --url https://example.com/terminal
```

Then anyone can install it with:

```sh
$ terminalwire install my-app
```

Listed apps get a shareable install page on [terminalwire.com/applications](/applications). To install a one-off without listing it, pass the URL directly:

```sh
$ terminalwire install --url https://example.com/terminal --name my-app
```

Either way the client drops a launcher at `~/.terminalwire/bin/my-app`, and updates itself from then on; you ship changes by deploying your server.
