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.
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.
IO.puts(IO.ANSI.format([:green, "✓ done"])) Owl.IO.puts(["deploying ", Owl.Data.tag("staging", :cyan)])
That’s why a library like Owl works unchanged: it writes to standard IO, and standard IO is the user’s terminal.
Write errors with
warn(orContext.warn/2). Don’t write to the:stderrdevice directly; that goes to your server’s console, not the client’s.
Input
gets reads a line. The prompt is optional.
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.
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.
@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.