pinexec

package module
v0.0.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 12, 2026 License: MIT Imports: 10 Imported by: 0

README

pinexec

A small, dependency-free Go package for running shell commands with cancellation, output sanitization, and live streaming.

pkg.go.dev

Why

os/exec is the right primitive when you need fine-grained control, but plugging it into an AI coding agent (or any UI that wants to display commands live while also persisting clean output) involves the same half-dozen chores every time:

  • cancel via context and make the kill recursive across the child's whole process group (so go run's compiled binary actually dies);
  • strip ANSI escapes from stored output without losing them for live display;
  • replace stray binary bytes (UTF-8 islands of \x01/\x02 when a program misdetects a pipe as a terminal) so the output is safe to feed into an LLM;
  • truncate runaway output, but keep the tail and spill the full version to a temp file;
  • force colors via CLICOLOR_FORCE/FORCE_COLOR when a UI is watching, even though stdout is a pipe.

pinexec does all of that behind one call:

res, err := pinexec.Execute(ctx, "make test", func(s string) {
    ui.Write(s) // live, ANSI preserved
})
// res.Output     — ANSI-stripped, sanitized, tail-truncated
// res.ExitCode   — 0 on success, -1 on cancel, …
// res.Cancelled  — true if ctx fired
// res.Truncated  — true if Output was tail-truncated
// res.FullOutputPath — non-empty if a temp-file spill was created

Install

go get github.com/kfet/pinexec

Requires Go 1.21+. Zero external dependencies.

Scope

Intentionally small. pinexec is a runner shaped for AI coding agents, not a general process-management library. It does not:

  • parse, lint, or rewrite shell input (use mvdan.cc/sh for that);
  • offer a fluent pipeline DSL (see bitfield/script);
  • expose every os/exec.Cmd knob (use os/exec directly).

What it does cover, and what makes it different from the alternatives, is the combination above: dual-output sandboxed $SHELL -c with cross-platform pgroup-kill, ANSI/binary sanitization, line+byte tail truncation, and live raw-chunk callbacks.

License

MIT — see LICENSE.

Documentation

Overview

Package pinexec runs shell commands with cancellation, output sanitization, and live streaming.

pinexec is a sandboxed "$SHELL -c" runner shaped for AI coding agents:

  • Combined stdout+stderr capture.
  • Cross-platform cancellation that kills the entire process group (so go run's compiled binary, npm spawn, make recipes, etc. all die when ctx is cancelled — not just the leader).
  • Dual output: live raw chunks (ANSI preserved) via an optional callback for UIs, plus a final ANSI-stripped, binary-sanitized output for LLM context.
  • Line + byte truncation with tail-keep; full output spills to a temp file when it exceeds the in-memory threshold.
  • Color env injection (CLICOLOR/CLICOLOR_FORCE/FORCE_COLOR) when a live callback is provided, so CLIs that gate ANSI on TTY detection still emit colors.

The headline API is Execute. The truncation and ANSI helpers (TruncateHead, TruncateTail, StripAnsi, AppendColorEnv) are exported for callers that want to apply the same shape to output they produce by other means.

pinexec is dependency-free and Go 1.21+.

Index

Examples

Constants

View Source
const (
	DefaultMaxLines   = 2000
	DefaultMaxBytes   = 50 * 1024 // 50KB
	GrepMaxLineLength = 500       // Max chars per grep match line
)

Default truncation limits.

Variables

This section is empty.

Functions

func AppendColorEnv

func AppendColorEnv(env []string) []string

AppendColorEnv appends environment variables that force CLI tools to emit ANSI color codes even when stdout is not a TTY. Covers:

  • CLICOLOR=1 — BSD/macOS convention to enable color (ls, etc.)
  • CLICOLOR_FORCE=3 — BSD/macOS convention to force color even without TTY
  • FORCE_COLOR=1 — Node.js/chalk convention (jest, vitest, etc.)

macOS /bin/ls requires CLICOLOR=1 in addition to CLICOLOR_FORCE=3 to emit color codes when stdout is not a TTY.

Existing values are not overwritten so the user can opt out.

func FormatSize

func FormatSize(bytes int) string

FormatSize formats bytes as human-readable size.

func StripAnsi

func StripAnsi(s string) string

StripAnsi removes ANSI escape sequences from a string.

func TruncateLine

func TruncateLine(line string, maxChars int) (text string, wasTruncated bool)

TruncateLine truncates a single line to maxChars, adding a [truncated] suffix. Used for grep match lines.

Types

type Result

type Result struct {
	// Output is the combined stdout+stderr of the command, with ANSI
	// escape sequences stripped, binary bytes replaced with '?', and
	// '\r' removed. If the raw output exceeded [DefaultMaxBytes] /
	// [DefaultMaxLines] only the tail is retained and
	// [Result.Truncated] is true.
	Output string

	// ExitCode is the process exit code, or -1 if the command was
	// cancelled via context, killed, or did not produce an exit
	// status for any other reason.
	ExitCode int

	// Cancelled is true if the call's context was cancelled before
	// the command finished.
	Cancelled bool

	// Truncated is true if [Result.Output] was truncated.
	Truncated bool

	// FullOutputPath, when non-empty, is the path to a temp file
	// containing the (sanitized, ANSI-stripped) full output of the
	// command. The file is created lazily once total output exceeds
	// [DefaultMaxBytes] and is not removed by pinexec; the caller
	// owns its lifecycle.
	FullOutputPath string
}

Result holds the outcome of an Execute call.

func Execute

func Execute(ctx context.Context, command string, onChunk func(chunk string)) (Result, error)

Execute runs command via $SHELL -c (falling back to /bin/sh), capturing combined stdout+stderr. The two streams are merged in arrival order; their relative ordering reflects when bytes arrived, not which stream produced them.

The command runs in its own process group on Unix so cancelling ctx kills the entire group, not just the shell — this is important for commands like `go run` that spawn a compiled binary the shell does not directly track. On Windows the standard process-tree termination from exec.CommandContext is used.

Output is sanitized (binary bytes replaced, '\r' stripped) and ANSI-stripped before being stored in Result.Output. When onChunk is non-nil, it is invoked with each raw output chunk as it arrives (ANSI preserved) for live display, and color-forcing environment variables (see AppendColorEnv) are injected so CLIs that gate ANSI output on TTY detection still emit colors. onChunk is called serially from a single goroutine; a slow callback back-pressures the read loop and may block the child process — keep it fast.

If total output exceeds DefaultMaxBytes, the full sanitized output is also streamed to a temp file whose path is returned in Result.FullOutputPath. The in-memory output is kept to roughly 2×[DefaultMaxBytes] via a rolling window, then further trimmed by TruncateTail to DefaultMaxBytes / DefaultMaxLines for the final Result.Output.

Execute returns a non-nil error only if the child process could not be started. A non-zero exit status is reported via Result.ExitCode, not as an error.

Execute is safe to call concurrently.

Example

Basic usage: capture combined stdout+stderr and the exit code.

package main

import (
	"context"
	"fmt"
	"strings"

	"github.com/kfet/pinexec"
)

func main() {
	ctx := context.Background()
	res, err := pinexec.Execute(ctx, "echo hello; echo world", nil)
	if err != nil {
		panic(err)
	}
	fmt.Println("exit:", res.ExitCode)
	fmt.Println(strings.TrimSpace(res.Output))
}
Output:
exit: 0
hello
world
Example (Streaming)

Stream live chunks (ANSI preserved) while the command runs. The final Result.Output is still ANSI-stripped for downstream consumers.

package main

import (
	"context"
	"fmt"
	"strings"

	"github.com/kfet/pinexec"
)

func main() {
	ctx := context.Background()
	var live strings.Builder
	res, _ := pinexec.Execute(ctx, "echo hi", func(chunk string) {
		live.WriteString(chunk)
	})
	fmt.Println("live:", strings.TrimSpace(live.String()))
	fmt.Println("stored:", strings.TrimSpace(res.Output))
}
Output:
live: hi
stored: hi

type TruncationOptions

type TruncationOptions struct {
	MaxLines int // 0 means use DefaultMaxLines
	MaxBytes int // 0 means use DefaultMaxBytes
}

TruncationOptions configures truncation limits.

type TruncationResult

type TruncationResult struct {
	Content               string // The (possibly truncated) content
	Truncated             bool   // Whether truncation occurred
	TruncatedBy           string // "lines", "bytes", or "" if not truncated
	TotalLines            int    // Total lines in original content
	TotalBytes            int    // Total bytes in original content
	OutputLines           int    // Lines in truncated output
	OutputBytes           int    // Bytes in truncated output
	LastLinePartial       bool   // Whether the first line (tail) was partially truncated
	FirstLineExceedsLimit bool   // Whether the first line exceeds the byte limit (head)
	MaxLines              int    // The max lines limit applied
	MaxBytes              int    // The max bytes limit applied
}

TruncationResult describes the outcome of a truncation operation.

func TruncateHead

func TruncateHead(content string, opts TruncationOptions) TruncationResult

TruncateHead truncates content from the head (keeps first N lines/bytes). Suitable for file reads where you want to see the beginning. Never returns partial lines. If the first line exceeds the byte limit, returns empty content with FirstLineExceedsLimit=true.

func TruncateTail

func TruncateTail(content string, opts TruncationOptions) TruncationResult

TruncateTail truncates content from the tail (keeps last N lines/bytes). Suitable for bash output where you want to see the end (errors, final results). May return partial first line if the last line of original content exceeds the byte limit.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL