A small, platform-neutral pty (pseudo terminal) backend for Go, plus an interactive test fixture built on top of it. Start a child process on a pty master and read/write its byte stream, resize the terminal and wait for its exit code — the same code on Linux, macOS and Windows (ConPTY).
| Package | Use it for |
|---|---|
github.com/gookit/gopty |
Product code: Spec + Start → Pty (Read / Write / Resize / Wait / Close), IsAvailable(), IsHostBlocked(err), IsBlockedExitCode(code) |
github.com/gookit/gopty/ptytest |
Test code: drive a program inside a pty from go test — Start / StartSpec, Mark, Expect / Contains / Count, Text (ANSI stripped) / RawText, Send / SendLine / SendCtrlC, WaitExit, and an automatic Close cleanup |
go get github.com/gookit/gopty
go get github.com/gookit/gopty/ptytest # the test fixtureRequires Go 1.23 or newer.
| GOOS | Backend | Dependency |
|---|---|---|
linux, macOS, *BSD |
creack/pty (the master file is the stream) | github.com/creack/pty |
windows (Win10 1809+) |
a vendored ConPTY copy in ./conpty, MIT |
golang.org/x/sys |
Both backends satisfy the same Pty contract, so a program that runs pty children compiles and behaves
the same on a unix worker and on Windows.
The Windows side deliberately keeps
golang.org/x/sysas its only dependency: it does not pull inhcsshimand its indirect chain the wayaymanbagabas/go-ptydoes.
import "github.com/gookit/gopty"
p, err := gopty.Start(gopty.Spec{Command: "bash", Args: []string{"-i"}, Cols: 100, Rows: 30})
if err != nil {
return err
}
defer p.Close()
_, _ = p.Write([]byte("echo hi\n"))
out, _ := io.ReadAll(p) // stdout + stderr, multiplexed
code, err := p.Wait(ctx) // blocks until the child exits (or ctx is cancelled)Spec fields:
| Field | Meaning |
|---|---|
Command, Args |
executable + args (argv[0] not included). On Windows they are joined into one safely-quoted command line (windows.EscapeArg), since ConPTY takes a command line, not an argv |
Env |
full process env; nil inherits the parent. On Windows duplicates are de-duplicated "last wins" (the os/exec rule) because CreateProcess keeps the first |
Dir |
working directory; "" inherits |
Cols, Rows |
initial terminal size; 0 uses the platform default |
Pty contract: Read carries stdout+stderr, Write is stdin, Resize sets the window size in cells,
Wait(ctx) returns the exit code (a real process exit is (code, nil), only a genuine reap failure
returns an error), Close releases the master and — on unix — kills the child. Teardown order belongs
to the caller's state machine; this package only provides the primitives.
import "github.com/gookit/gopty/ptytest"
func TestAskName(t *testing.T) {
s := ptytest.Start(t, "sh", "-c", `printf "name? "; read n; echo got=$n`)
mark := s.Mark()
if !s.Expect(`name\?`, mark) {
t.Fatalf("no prompt, output:\n%s", s.Text())
}
s.SendLine("tom")
if !s.Expect(`got=tom`, mark) {
t.Fatalf("no answer, output:\n%s", s.Text())
}
}It is the Go counterpart of a "pty smoke script" (what a Python pty driver or a Windows console harness used to provide), so the same test code runs on every platform.
Rules of thumb:
- assert only on output produced after a
Mark— earlier turns would satisfy the check by accident; - wait for the prompt (
Expect) before typing: a program that clears pending input before asking drops too-early keystrokes; Enteris"\r", Ctrl-C is0x03(useSendCtrlC), Ctrl-D is0x04;Text()strips ANSI escapes,RawText()keeps them (colour-only output is invisible toText);Startskips the test with a reason when no pty backend is available or the host refuses a pty child, and registers its ownClosecleanup.
- ConPTY needs Win10 1809+;
IsAvailable()probes it andptytest.Startskips when it is missing. - Some Windows process contexts refuse a ConPTY child: inside a job object that does not allow
breakaway,
CreateProcessfails withERROR_ACCESS_DENIED, or the child dies at load withSTATUS_DLL_INIT_FAILED(0xC0000142). UseIsHostBlocked(err)/IsBlockedExitCode(code)to detect it and degrade;ptytestskips with the reason instead of reporting a bare error code. The outcome is environment-dependent — measured both ways inside the same agent job (cmd.exestarted fine, another harness was blocked) — so do not assume either result. - When the host blocks ConPTY and you still need to test interactive prompts, drive a real console:
AllocConsole, child stdin =CONIN$, then send keys withWriteConsoleInput. - After the child has exited on its own,
Close()first drains what conhost is still forwarding, so the last lines a short-lived child printed are not discarded: ConPTY's output pipe does not report EOF while the pseudo console is open (closing it is what ends the stream), so the drain waits for the pipe to go quiet (250ms without a read) instead of for EOF, and gives up at 2s (drainGrace, the same bound the unix backend uses). A live child (cancel) is torn down at once. Close()is idempotent. The vendoredconpty.Close()closes four pipe handles and the pseudo console unconditionally, and a second close would release handle values Windows may have reused since (observed to take the test binary down silently); the adapter guards it withsync.Once.- A synthetic Ctrl-C written to the input (
0x03) is not a realCTRL_C_EVENT; the child's console mode decides how it is read.
- The parent keeps its own slave fd until the child has been reaped, so the reaper's close is the last one. On darwin the BSD tty layer flushes master-unread output on the last close of the slave, which would lose everything a short-lived child printed before the reader caught up.
- After the child has exited,
Close()first waits (bounded, 2s) for the reader to reach EOF, so the output written right before exit is not discarded; a live child (cancel) is torn down at once.
go test ./... # host platform
go test -race ./... # unix
GOOS=windows go build ./... # cross-compile check (also GOOS=darwin, GOOS=linux)
go vet ./...CI (.github/workflows/go.yml) runs the tests on ubuntu + windows for Go 1.23 / 1.24 / 1.25 / stable, plus macOS on 1.23 and stable, and the race detector on the ubuntu/stable leg. On a runner whose session cannot host a ConPTY child, the Windows pty cases skip and say so in the log.
- inhere/gofer — pty runner for interactive agent sessions (
internal/runner/pty); - gookit/gcli — interactive end-to-end tests of prompts and shared options (
e2e_pty_test.go).
MIT, see LICENSE. The vendored ./conpty package keeps its own copy in
conpty/LICENSE.