# "Command Execution" _Path: en/lua/dynamic/exec_ > "Execute external commands and shell scripts with full control over I/O streams." ## Table of Contents - Command Execution ## Content # Command Execution Execute external commands and shell scripts with full control over I/O streams. For executor configuration, see [Executor](system/exec.md). ## Loading ```lua local exec = require("exec") ``` ## Acquiring an Executor Get a process executor resource by ID: ```lua local executor, err = exec.get("app:exec") if err then return nil, err end -- Use executor local proc = executor:exec("ls -la") -- ... -- Release when done executor:release() ``` | Parameter | Type | Description | |-----------|------|-------------| | `id` | string | Resource ID | **Returns:** `Executor, error` ## Creating a Process Create a new process with the specified command: ```lua -- Simple command local proc, err = executor:exec("echo 'Hello, World!'") -- With working directory local proc = executor:exec("npm install", { work_dir = "/app/project" }) -- With environment variables local proc = executor:exec("python script.py", { work_dir = "/scripts", env = { PYTHONPATH = "/app/lib", DEBUG = "true", API_KEY = api_key } }) -- Run shell script local proc = executor:exec("./deploy.sh production", { work_dir = "/app/scripts", env = { DEPLOY_ENV = "production" } }) ``` | Parameter | Type | Description | |-----------|------|-------------| | `cmd` | string | Executable and literal arguments | | `options.work_dir` | string | Working directory | | `options.env` | table | Environment variables | | `options.pty` | table | Allocate a pseudo-terminal for the child | | `options.process_group` | boolean | Start the child in its own process group so signals also reach descendants; unsupported on Windows | **Returns:** `Process, error` The process is created but not started. ### Command Parsing `cmd` is split into an executable and literal arguments using shell-like quoting: single and double quotes group a word, and a backslash escapes the following character. There is no shell, so no variable expansion, globbing, pipes, or redirection happens. An unclosed quote returns `errors.INVALID`. ```lua -- One argument containing a space, passed literally local proc = executor:exec("grep 'hello world' notes.txt") -- $HOME is passed as the five characters $HOME, not expanded local proc = executor:exec("echo $HOME") ``` To use shell features, invoke a shell explicitly: ```lua local proc = executor:exec("/bin/sh -c 'ls *.log | wc -l'") ``` ### PTY Options Allocating a PTY gives the child a real terminal: line editing, job control, and full-screen programs work as they do in a shell. ```lua local proc = executor:exec("/bin/bash --noprofile --norc", { pty = {width = 100, height = 30, term = "xterm-256color"}, }) ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `width` | number | 80 | Initial PTY columns, 1 to 65535 | | `height` | number | 24 | Initial PTY rows, 1 to 65535 | | `term` | string | none | Child `TERM` value | Width times height may not exceed 262,144 cells. A PTY-backed process merges the child's output into a single terminal stream; drive it with [resize](#resize) and [attach_terminal](#attach_terminal) rather than the stdin/stdout pipe methods. ## start / wait Start the process and wait for completion. ```lua local proc = executor:exec("./build.sh") local ok, err = proc:start() if err then return nil, err end local exit_code, err = proc:wait() if err then return nil, err end if exit_code ~= 0 then return nil, errors.new({ kind = errors.INTERNAL, message = "Build failed with exit code: " .. exit_code }) end ``` ## stdout_stream / stderr_stream Get streams to read process output. ```lua local proc = executor:exec("./process-data.sh") local stdout = proc:stdout_stream() local stderr = proc:stderr_stream() proc:start() -- Read all stdout local output = {} while true do local chunk = stdout:read(4096) if not chunk then break end table.insert(output, chunk) end local result = table.concat(output) -- Check for errors local err_output = {} while true do local chunk = stderr:read(4096) if not chunk then break end table.insert(err_output, chunk) end local exit_code = proc:wait() stdout:close() stderr:close() if exit_code ~= 0 then return nil, errors.new({ kind = errors.INTERNAL, message = table.concat(err_output) }) end return result ``` ## write_stdin Write data to process stdin. ```lua local proc = executor:exec("head -n 3") local stdout = proc:stdout_stream() proc:start() proc:write_stdin("banana\napple\ncherry\n") local lines = stdout:read() proc:wait() stdout:close() ``` Each call writes the given bytes and returns. Call `close_stdin()` when the child must see EOF: ```lua local proc = assert(executor:exec("sort")) local stdout = assert(proc:stdout_stream()) assert(proc:start()) assert(proc:write_stdin("banana\napple\n")) assert(proc:close_stdin()) local sorted = assert(stdout:read()) ``` `close_stdin()` is idempotent. Later writes fail because the input side is closed. PTY-backed processes do not expose this pipe operation. ## done Use `done()` to observe exit without consuming the process handle: ```lua local proc = assert(executor:exec("./worker")) assert(proc:start()) local exits = assert(proc:done()) local status, open = exits:receive() if open then print(status.code, status.signal, status.error) end ``` The returned channel delivers one exit record and then closes. Repeated calls return the same channel. The record contains `code`, optional `signal`, and an `error` only when the runtime could not observe the exit. A signal exit uses `128 + signal` as its code. Unlike `wait()`, `done()` leaves the handle usable, so streams, `signal()`, and `close()` remain available. `wait()` after delivery returns the recorded code. ## signal / close Send signals or release the process. ```lua local proc = executor:exec("./long-running-server.sh") proc:start() -- ... later, need to stop it ... -- Send SIGTERM and release the handle proc:close() -- Send SIGKILL and release the handle proc:close(true) -- Or send a specific signal and keep the handle local SIGINT = 2 proc:signal(SIGINT) ``` `close(force?)` signals a started child with `SIGTERM`, or `SIGKILL` when `force` is true, then reaps it in the background so the call does not block. A child still running after a grace period is killed so the reap always completes. An unstarted handle is simply invalidated, and closing twice is not an error. When `process_group` is enabled, signals target the group and still reach descendants after the leader exits. Streams acquired before reaping remain readable until their last writer closes, including a descendant that inherited the pipe. After `close()`, process methods report `process closed`; use `done()` when the exit matters and the handle must remain usable. ## resize Resize the PTY of a PTY-backed process. A pipe-backed process returns an error. ```lua local ok, err = proc:resize(120, 40) ``` | Parameter | Type | Description | |-----------|------|-------------| | `width` | number | Columns, 1 to 65535 | | `height` | number | Rows, 1 to 65535 | **Returns:** `boolean, error` Use it to set the initial geometry before handing the process to a terminal session. Once a session owns the process, send it a `resize` event instead. ## attach_terminal Attach an unstarted PTY-backed process to the calling process's terminal and return a `TerminalSession`. ```lua local exec = require("exec") local tty = require("tty") local executor = assert(exec.get("app:exec")) local proc = assert(executor:exec("/bin/bash --noprofile --norc", { pty = {term = "xterm-256color"}, })) local session = assert(proc:attach_terminal()) ``` **Returns:** `TerminalSession, error` The call consumes the process: the session becomes its sole lifecycle owner and the original handle can no longer be used. The session opens a surface on the current terminal port and owns PTY emulation, input encoding, resize, graceful and forced termination, and reaping. It needs a terminal port — a [terminal host](system/terminal.md) process, or a process spawned with a [viewport grant](lua/system/tty.md#viewport) — and fails when the port has no input controller or already has an open surface. ### TerminalSession | Method | Returns | Description | |--------|---------|-------------| | `send(event)` | `boolean, error` | Forward one canonical TTY event to the child | | `done()` | channel | Channel that fires once when the child finishes | | `status()` | `string, error` | `"running"` or `"done"`, with the failure error when it failed | | `close()` | `boolean, error` | Request termination of a running child | `send` accepts the key, mouse, resize, focus, and paste records described in [TTY](lua/system/tty.md#event-types). Sending after the child has finished returns an error. ```lua local channel = require("channel") local events = assert(tty.events()) assert(tty.start()) local done = session:done() while true do local selected = channel.select({ events:case_receive(), done:case_receive(), }) if not selected.ok or selected.channel == done then break end if selected.value.type == "close" then break end assert(session:send(selected.value)) end assert(session:close()) ``` ## Permissions Exec operations are subject to security policy evaluation. | Action | Resource | Description | |--------|----------|-------------| | `exec.get` | Executor ID | Acquire an executor resource | | `exec.run` | Command | Execute a specific command | `exec.run` is evaluated against the raw command string, with the requested options as metadata: | Key | Type | Description | |-----|------|-------------| | `work_dir` | string | Requested working directory, empty when unset | | `env_names` | string[] | Names of the environment variables passed, sorted; values are not exposed | | `pty.requested` | boolean | Whether a PTY was requested | | `pty.width` | number | Resolved PTY columns, present when requested | | `pty.height` | number | Resolved PTY rows, present when requested | | `pty.term` | string | Requested `TERM` value, present when requested | A policy can therefore allow plain commands while restricting the ones that ask for a terminal or a particular working directory. ## Errors | Condition | Kind | Retryable | |-----------|------|-----------| | Invalid ID | `errors.INVALID` | no | | Permission denied | `errors.INVALID` | no | | Process closed | `errors.INVALID` | no | | Process not started | `errors.INVALID` | no | | Already started | `errors.INVALID` | no | | Unclosed quote in command | `errors.INVALID` | no | | No PTY on the process | `errors.INVALID` | no | | Terminal port unavailable | `errors.UNAVAILABLE` | no | See [Error Handling](lua/core/errors.md) for working with errors. ## See Also - [Executor](system/exec.md) — executor configuration - [TTY](lua/system/tty.md) — terminal events, surfaces, and viewports - [Terminal UI](tutorials/tty.md) — a shell that hosts a PTY child in a viewport ## Navigation Previous: "Dynamic Evaluation" (lua/dynamic/eval) Next: "Expression Language" (lua/dynamic/expression)