# Command buffers: a field guide

This is a condensed, example-first guide to writing Go automation with
[lesiw.io/command](https://pkg.go.dev/lesiw.io/command) and
[lesiw.io/fs](https://pkg.go.dev/lesiw.io/fs). It is the companion to
[cmdbuf.io](https://cmdbuf.io) and is written to be equally useful to
people and to coding agents. Every example in it compiles and runs
against the current releases.

## The mental model

Command buffers treat automation as what it physically is: bytes moving
between commands and files. Five facts cover the whole design.

1. A `Buffer` is a command's execution as an `io.Reader`. The command
   starts on the first `Read` and is finished at `io.EOF`. There is no
   `Start` and no `Wait`.
2. A `Machine` is anything that can run a command. It has one method:
   `Command(ctx context.Context, arg ...string) Buffer`.
3. Machines wrap machines. `ssh.Machine`, `ctr.Machine`, and
   `sub.Machine` each take a Machine and return a new one, so
   environments nest: a container on a remote host is
   `ctr.Machine(ssh.Machine(sys.Machine(), "ssh", "user@host"), "img")`.
4. Files are buffers on machines too. `command.FS(m)` returns a
   filesystem for any Machine. Copying between machines is `io.Copy`.
   A trailing slash means a directory, which streams as a tar archive.
5. `command.Shell` (type `*command.Sh`) makes automation portable:
   it wraps a Machine with operations that work on any machine plus
   an explicit list of allowed external commands. Everything not on
   the list fails with "command not found" at call time.

There is no shell in this vocabulary. No `sh -c`, no `bash -c`, no
`cmd /c`, no string splitting, no quoting rules. Arguments are passed
as they are written. Anything a shell would have done — piping,
substitution, redirection — has a Go expression instead, listed below.

## Translating from shell

| Shell idiom | Command buffer equivalent |
|---|---|
| `out=$(git describe)` | `out, err := command.Read(ctx, m, "git", "describe")` |
| `cmd >/dev/null 2>&1` | `err := command.Do(ctx, m, "cmd")` |
| run interactively | `err := command.Exec(ctx, m, "cmd")` |
| `a \| b` | `io.Copy(command.NewWriter(ctx, m, "b"), command.NewReader(ctx, m, "a"))` |
| `a \| b \| c` | `command.Copy(dst, src, command.NewFilter(ctx, m, "b"))` |
| `VAR=x cmd` | `command.Do(command.WithEnv(ctx, map[string]string{"VAR": "x"}), m, "cmd")` |
| `echo $VAR` | `command.Env(ctx, m, "VAR")` |
| `cat file` | `sh.ReadFile(ctx, "file")` |
| `echo x > file` | `sh.WriteFile(ctx, "file", []byte("x"))` |
| `mkdir -p dir` | `sh.MkdirAll(ctx, "dir")` |
| `rm -rf dir` | `sh.RemoveAll(ctx, "dir")` |
| `mv a b` | `sh.Rename(ctx, "a", "b")` |
| `chmod 755 f` | `sh.Chmod(ctx, "f", 0o755)` |
| `mktemp` / `mktemp -d` | `sh.Temp(ctx, "prefix")` / `sh.Temp(ctx, "prefix/")` |
| `test -e file` | `_, err := sh.Stat(ctx, "file")` |
| `which cmd` | `command.NotFound(command.Do(ctx, m, "cmd", "--version"))` |
| `uname -s` / `uname -m` | `sh.OS(ctx)` / `sh.Arch(ctx)` |
| `tar -cf- dir` | `sh.Open(ctx, "dir/")` |
| `tar -xf- -C dir` | `sh.Create(ctx, "dir/")` |
| `scp f host:f` | `io.Copy` between two machines' shells (see Files) |
| `ssh host cmd` | `command.Read(ctx, ssh.Machine(m, "ssh", "host"), "cmd")` |
| `docker exec ctr cmd` | `command.Read(ctx, ctr.Machine(m, "image"), "cmd")` |
| `set -x` | `command.Trace = os.Stderr` |
| `grep`, `awk`, `sed` | Go stdlib: `bufio.Scanner`, `strings`, `regexp` |

## Program skeleton

Standalone automation uses the `run()` pattern, so errors have one exit
path and the code stays testable:

```go
package main

import (
    "context"
    "fmt"
    "os"

    "lesiw.io/command"
    "lesiw.io/command/sys"
)

func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func run() error {
    ctx := context.Background()
    sh := command.Shell(sys.Machine(), "git")

    commit, err := sh.Read(ctx, "git", "rev-parse", "HEAD")
    if err != nil {
        return fmt.Errorf("read commit: %w", err)
    }
    fmt.Println(commit)
    return nil
}
```

## Running commands

Three helpers cover nearly every call. Choose by what you want from the
output:

```go
// Capture output as a string. Trailing newlines are stripped,
// like $(...) in a shell.
version, err := command.Read(ctx, m, "go", "version")

// Discard output; run for the side effect or the error.
err = command.Do(ctx, m, "gofmt", "-l", ".")

// Attach output (and, when possible, the terminal) to the user.
// Use for builds, tests, and anything interactive.
err = command.Exec(ctx, m, "go", "test", "./...")
```

All three exist as methods on `*command.Sh` with the same semantics:
`sh.Read`, `sh.Do`, `sh.Exec`.

Environment variables travel on the context:

```go
ctx = command.WithEnv(ctx, map[string]string{"CGO_ENABLED": "0"})
err := command.Exec(ctx, m, "go", "build", ".")
```

So does the working directory:

```go
ctx = fs.WithWorkDir(ctx, "/opt/app")
out, err := command.Read(ctx, m, "pwd") // "/opt/app"
```

To see every command as it runs (like `set -x`):

```go
command.Trace = os.Stderr
```

### Errors

Failed commands return a `*command.Error` carrying the exit code and
any stderr output:

```go
err := command.Do(ctx, m, "go", "build", ".")
var cerr *command.Error
if errors.As(err, &cerr) {
    fmt.Println("exit code:", cerr.Code)
    fmt.Printf("stderr:\n%s", cerr.Log)
}
```

When a multi-stage `command.Copy` pipeline fails, the returned error
reports every stage and its outcome, so the failing stage is
identifiable:

```go
_, err := command.Copy(
    command.NewWriter(ctx, m, "wc", "-c"),
    command.NewReader(ctx, m, "echo", "not gzip data"),
    command.NewFilter(ctx, m, "gzip", "-d"),
)
fmt.Println(err)
// <*command.reader>
//     <success>
//
// <*command.filter>
//     exit status 1
//         gzip: unknown compression format
```

To check whether a command exists, run it and test the error — there
is no `which`:

```go
err := command.Do(ctx, m, "gotestsum", "--version")
if command.NotFound(err) {
    return sh.Exec(ctx, "go", "install", "gotest.tools/gotestsum@latest")
}
```

## Piping

Two stages are `io.Copy`. The writer's `ReadFrom` optimization closes
stdin automatically when the source hits EOF:

```go
// echo "hello, pipes" | tee hello.txt
_, err := io.Copy(
    command.NewWriter(ctx, m, "tee", "hello.txt"),
    command.NewReader(ctx, m, "echo", "hello, pipes"),
)
```

Three or more stages are `command.Copy`, with `command.NewFilter` for
the middle stages. Commands and files mix freely in one pipeline —
this dumps a database, compresses it in flight, and lands it on a
different machine with no intermediate files:

```go
backup := ssh.Machine(m, "ssh", "backup@vault.example.com")
_, err := command.Copy(
    command.NewWriter(ctx, backup, "tee", "db.sql.gz"),
    command.NewReader(ctx, m, "pg_dumpall"),
    command.NewFilter(ctx, m, "gzip"),
)
```

Buffers compose with anything that speaks `io`: `strings.NewReader` as
a source, `bytes.Buffer` as a sink, `io.MultiWriter` for tees.

```go
// kubectl apply -f - <<< "$manifest"
_, err := io.Copy(
    command.NewWriter(ctx, m, "kubectl", "apply", "-f", "-"),
    strings.NewReader(manifest),
)
```

## Machines

The provided machines, and what wraps what:

```go
m := sys.Machine()                                  // the local system
m := ssh.Machine(host, "ssh", "user@example.com")   // a remote host, via ssh run on `host`
m := ctr.Machine(host, "alpine:latest")             // a container, via docker/podman/nerdctl on `host`
m := sub.Machine(host, "busybox")                   // `host`, with every command prefixed
m := mem.Machine()                                  // in-memory: echo, cat, tee, tr; Playground-safe
m := new(mock.Machine)                              // programmable responses for tests
```

Notes:

- `ssh.Machine`'s variadic arguments are the **entire ssh command
  line, including the `ssh` command itself**. This is what makes
  ports, identity files, `sshpass`, `autossh`, and jump hosts all
  work with no special API: `ssh.Machine(sys.Machine(), "sshpass", "-p", pw,
  "ssh", "-p", "2222", "user@host")`.
- `ctr.Machine` starts its container lazily on first use and finds
  docker, podman, or nerdctl automatically. Pair it with
  `defer command.Shutdown(ctx, m)` to clean the container up.
  If the image name starts with `/` or `.`, it is treated as a path
  to a Containerfile and built.
- `command.Shutdown` is a no-op for machines with nothing to clean
  up, so it is always safe to call.
- OS and architecture detection: `command.OS(ctx, m)` and
  `command.Arch(ctx, m)` return normalized GOOS/GOARCH values
  ("linux", "darwin", "windows"; "amd64", "arm64") by probing the
  machine. `sh.OS(ctx)` and `sh.Arch(ctx)` cache the result.
- Windows works fully as the local system (`sys.Machine()` on a
  Windows host). Reaching a machine over `ssh.Machine` currently
  assumes a POSIX shell on the remote end, so Windows hosts are not
  yet reachable as ssh targets.

### Composition

Machines take machines, so environments nest by construction:

```go
// A container on a remote build host.
host := ssh.Machine(sys.Machine(), "ssh", "admin@build.example.com")
m := ctr.Machine(host, "golang:latest")
defer command.Shutdown(ctx, m)

sh := command.Shell(m, "go")
err := sh.Exec(ctx, "go", "test", "./...")
```

A prefix glued onto a command string — `ssh host ...`,
`docker exec ...`, `env FOO=bar ...` — is a machine that hasn't been
named yet. Once it is one, the code that runs commands no longer
depends on where they run.

A machine can also be a single command, enriched. `command.HandleFunc`
routes one command name through a function; this shim injects an
environment variable into every `go` invocation:

```go
m := command.HandleFunc(sys.Machine(), "go",
    func(ctx context.Context, args ...string) command.Buffer {
        ctx = command.WithEnv(ctx, map[string]string{
            "GOFLAGS": "-trimpath",
        })
        return sys.Machine().Command(ctx, args...)
    })

flags, err := command.Read(ctx, m, "go", "env", "GOFLAGS")
// flags == "-trimpath"
```

`command.MachineFunc` adapts any function into a Machine, the same way
`http.HandlerFunc` adapts functions into handlers.

## Files

`command.FS(m)` returns a `lesiw.io/fs.FS` for any machine. Machines
with native filesystem access (local, in-memory) use it; for the rest,
file operations are implemented with whatever commands the target
system has (`tee` on Unix, `Remove-Item` on Windows). Calling code is
identical either way, and automation written this way is
cross-platform by default: both libraries' CI suites run on Linux,
macOS, Windows, FreeBSD, and Alpine.

```go
fsys := command.FS(m)
err := fs.WriteFile(ctx, fsys, "hello.txt", []byte("Hello!\n"))
data, err := fs.ReadFile(ctx, fsys, "hello.txt")
```

`*command.Sh` exposes the same operations as methods: `ReadFile`,
`WriteFile`, `Open`, `Create`, `Append`, `Stat`, `Remove`, `RemoveAll`,
`Rename`, `MkdirAll`, `Chmod`, `Temp`, `Glob`, `ReadDir`, `Walk`, and
more.

Copying a file between machines is `io.Copy` of a lazy read buffer
into a lazy write buffer:

```go
local := command.Shell(sys.Machine())
remote := command.Shell(ssh.Machine(sys.Machine(), "ssh", "deploy@prod.example.com"))

_, err := io.Copy(
    remote.CreateBuffer(ctx, "/opt/app/server"),
    local.OpenBuffer(ctx, "bin/server"),
)
```

### Directories

A trailing slash means a directory, and directories stream as tar
archives:

```go
// Copy a local directory into a container.
dst, err := remote.Create(ctx, "/app/") // create/empty directory, accept tar
if err != nil {
    return err
}
defer dst.Close()
src, err := local.Open(ctx, "src/") // read directory as tar
if err != nil {
    return err
}
defer src.Close()
_, err = io.Copy(dst, src)
```

`Create("dir/")` replaces the directory's contents; `Append("dir/")`
adds to it; `Open("dir/")` reads it out. The same three verbs you
already use for files.

### File modes

Permissions travel on the context, where they apply to every operation
in the chain (including implicitly created parent directories):

```go
err := sh.WriteFile(
    fs.WithFileMode(ctx, 0o755),
    "hello.sh",
    []byte("#!/bin/sh\necho hello\n"),
)
```

### Temp files

```go
f, err := sh.Temp(ctx, "build")  // temp file; "build/" for a temp directory
if err != nil {
    return err
}
defer sh.RemoveAll(ctx, f.Path())
defer f.Close()
```

## Shell discipline

`command.Shell(machine, commands...)` registers the external commands
the automation is allowed to run. Keep the list to genuine external
tools:

```go
sh := command.Shell(sys.Machine(), "go", "git", "docker")
```

Do not register filesystem or text utilities — `cat`, `ls`, `mkdir`,
`rm`, `mv`, `chmod`, `stat`, `find`, `tar`, `mktemp`, `uname`, `grep`,
`tee`, `echo`. Each has a portable replacement (see the translation
table) that also works on machines where the command doesn't exist,
Windows included. A Shell that declares `"go", "git"` at the top of
the file is documentation: those are the automation's true
dependencies.

To register a command after construction, route it to the underlying
machine:

```go
sh = sh.Handle("kubectl", sh.Unshell())
```

## Testing

`mock.Machine` records calls and returns programmed responses.
Unprogrammed commands succeed with empty output, so tests only
specify what they care about. `mock.Calls` retrieves what ran,
piercing through a Shell:

```go
func Deploy(ctx context.Context, sh *command.Sh) error {
    branch, err := sh.Read(ctx, "git", "branch", "--show-current")
    if err != nil {
        return fmt.Errorf("read branch: %w", err)
    }
    return sh.Exec(ctx, "git", "push", "origin", branch)
}

func TestDeploy(t *testing.T) {
    m := new(mock.Machine)
    m.Return(strings.NewReader("main\n"), "git", "branch", "--show-current")

    sh := command.Shell(m, "git")
    if err := Deploy(t.Context(), sh); err != nil {
        t.Fatal(err)
    }

    got := mock.Calls(sh, "git")
    want := []mock.Call{
        {Args: []string{"git", "branch", "--show-current"}},
        {Args: []string{"git", "push", "origin", "main"}},
    }
    if !cmp.Equal(want, got) {
        t.Errorf("git calls mismatch (-want +got):\n%s", cmp.Diff(want, got))
    }
}
```

Testing notes:

- `command.Read` strips trailing newlines, so mock responses work with
  or without `\n`.
- More specific mocked commands take precedence over less specific
  ones: `m.Return(r, "git", "push")` beats `m.Return(r, "git")` for
  `git push` calls.
- To fail a command: `m.Return(command.Fail(&command.Error{Code: 1}),
  "exit", "1")`.
- OS-dependent code: `m.SetOS("windows")` and `m.SetArch("amd64")` —
  never mock `uname`.
- `mock.Machine` includes an in-memory filesystem. Pre-populate it
  with `WriteFile` before the code under test runs; inspect it after.
- For package-level shells, swap them out for the test's duration:

```go
func swap[T any](t *testing.T, ptr *T, val T) {
    t.Helper()
    old := *ptr
    *ptr = val
    t.Cleanup(func() { *ptr = old })
}

var sh = command.Shell(sys.Machine(), "go")

func TestBuild(t *testing.T) {
    swap(t, &sh, command.Shell(new(mock.Machine), "go"))
    // ...
}
```

- For quick one-off machines, `command.MachineFunc` adapts a function;
  `mem.Machine()` provides working echo/cat/tee/tr over an in-memory
  filesystem for examples and Playground use.

## Common mistakes

Each of these compiles (or nearly compiles) and then fails at run time.

**Shelling out.** `sh.Read(ctx, "sh", "-c", "a | b")` reintroduces
quoting bugs and fails on any machine without `sh`, including Windows.
Piping is `io.Copy`; substitution is `command.Read`; there is no shell
in the vocabulary and no need for one.

**Probing with `which`.** `which` is absent on Windows and minimal
containers. Run the actual command and check
`command.NotFound(err)`.

**Forgetting `"ssh"` in `ssh.Machine`.** The arguments are the whole
command line: `ssh.Machine(sys.Machine(), "ssh", "user@host")`.
Passing only `"user@host"` tries to execute a program named
`user@host`.

**Registering utilities on a Shell.** If `cat`, `mkdir`, or `tar`
appear in a `command.Shell(...)` call, a portable method was missed.
The exception is when the utility is genuinely the tool under
automation (for example, streaming into `tee` on a raw Machine as part
of a pipeline).

**Expecting commands to run eagerly.** Creating a buffer runs nothing.
`command.NewReader(ctx, m, "reboot")` is inert until something reads
it. The helpers (`Read`, `Do`, `Exec`) both create and drive the
buffer, which is why most code uses them.

**Reaching for the `os` package.** `os.Getenv` reads the local
process's environment, not the machine's. Use `command.Env(ctx, m,
key)` and `command.WithEnv`. Same for `os.Getwd` versus
`fs.WorkDir(ctx)` / `fs.WithWorkDir`.

**Capturing with the wrong helper.** `Exec` streams to the terminal
and captures nothing. `Read` captures. `Do` discards. The choice
follows from where the output should go.

**Inventing API.** If something seems missing — a `NewStream`, a
`NotFound` field on `Error`, an eager `Run` — check
[pkg.go.dev/lesiw.io/command](https://pkg.go.dev/lesiw.io/command)
first. The vocabulary is small and deliberate; the function that
exists is usually simpler than the one being imagined.

## Quick reference

```go
// Execution
command.Read(ctx, m, args...) (string, error)   // capture output
command.Do(ctx, m, args...) error               // discard output
command.Exec(ctx, m, args...) error             // attach to terminal

// Buffers and piping
command.NewReader(ctx, m, args...) io.ReadCloser    // Close cancels
command.NewWriter(ctx, m, args...) io.WriteCloser   // Close waits
command.NewFilter(ctx, m, args...) io.ReadWriteCloser
command.Copy(dst, src, filters...) (int64, error)

// Environment and context
command.WithEnv(ctx, map[string]string) context.Context
command.Env(ctx, m, key) string
fs.WithWorkDir(ctx, dir) context.Context
fs.WithFileMode(ctx, mode) context.Context
fs.WithDirMode(ctx, mode) context.Context

// Machines
sys.Machine()                     // local
ssh.Machine(m, sshCmdline...)     // remote; args include "ssh" itself
ctr.Machine(m, image, args...)    // container; Shutdown to clean up
ctr.Ctl(m)                        // the container CLI itself
sub.Machine(m, prefix...)         // prefix every command
mem.Machine()                     // in-memory, Playground-safe
new(mock.Machine)                 // tests
command.MachineFunc(fn)           // function as Machine
command.Handle(m, name, handler)  // route one command name
command.HandleFunc(m, name, fn)
command.Shutdown(ctx, m)          // cleanup; safe on any machine
command.OS(ctx, m), command.Arch(ctx, m)

// Shell
command.Shell(m, commands...) *command.Sh
sh.Read / sh.Do / sh.Exec
sh.ReadFile / sh.WriteFile / sh.Open / sh.Create / sh.Append
sh.Stat / sh.Remove / sh.RemoveAll / sh.Rename / sh.MkdirAll
sh.Chmod / sh.Temp / sh.Glob / sh.ReadDir / sh.Walk
sh.OS / sh.Arch / sh.Env / sh.Handle / sh.Unshell

// Errors
command.NotFound(err) bool
*command.Error{Code int, Log []byte, Err error}

// Tracing
command.Trace = os.Stderr

// Testing
m.Return(reader, args...)
mock.Calls(m, name) []mock.Call
m.SetOS(os), m.SetArch(arch)
command.Fail(err) command.Buffer
```

Full documentation: [pkg.go.dev/lesiw.io/command](https://pkg.go.dev/lesiw.io/command)
and [pkg.go.dev/lesiw.io/fs](https://pkg.go.dev/lesiw.io/fs).
