// SessionForge records a command's output streams with per-chunk timing and
// replays them later at true speed. Part of the Techlosoft "Remote Ops
// Workspace" product line.
package main

import (
	"bufio"
	"bytes"
	"encoding/base64"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"os/exec"
	"os/signal"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"syscall"
	"time"
	"unicode/utf8"
)

const (
	progName    = "sessionforge"
	progVersion = "1.0.0"
	castVersion = 1
	readChunk   = 32 * 1024
	maxCastLine = 8 * 1024 * 1024
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (identical across the tool family).
// ---------------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Cast format
// ---------------------------------------------------------------------------

// castHeader is the first line of a cast file.
type castHeader struct {
	Version   int      `json:"version"`
	Command   []string `json:"command"`
	StartedAt string   `json:"started_at"`
	Shell     string   `json:"shell"`
	Width     int      `json:"width"`
	Height    int      `json:"height"`
	Title     string   `json:"title"`
}

// castFooter is the optional trailing line of a cast file.
type castFooter struct {
	ExitCode    int     `json:"exit_code"`
	Duration    float64 `json:"duration"`
	Signal      string  `json:"signal,omitempty"`
	Interrupted bool    `json:"interrupted,omitempty"`
}

type castEvent struct {
	Time   float64
	Stream string // "o" or "e"
	Data   []byte
}

type cast struct {
	Header castHeader
	Events []castEvent
	Footer *castFooter
}

// encodeEvent renders one event line. Chunks that are valid UTF-8 are stored as
// plain JSON strings (asciinema-like); chunks that are not are stored base64
// with a trailing "b64" marker so arbitrary bytes survive the round trip.
func encodeEvent(t float64, stream string, data []byte) []byte {
	var b bytes.Buffer
	b.WriteByte('[')
	b.WriteString(strconv.FormatFloat(t, 'f', 6, 64))
	b.WriteString(", \"")
	b.WriteString(stream)
	b.WriteString("\", ")
	if utf8.Valid(data) {
		enc, _ := json.Marshal(string(data))
		b.Write(enc)
	} else {
		enc, _ := json.Marshal(base64.StdEncoding.EncodeToString(data))
		b.Write(enc)
		b.WriteString(", \"b64\"")
	}
	b.WriteByte(']')
	return b.Bytes()
}

func decodeEvent(line []byte) (castEvent, error) {
	var raw []json.RawMessage
	if err := json.Unmarshal(line, &raw); err != nil {
		return castEvent{}, fmt.Errorf("malformed event: %v", err)
	}
	if len(raw) < 3 {
		return castEvent{}, errors.New("event needs at least 3 fields")
	}
	var ev castEvent
	if err := json.Unmarshal(raw[0], &ev.Time); err != nil {
		return castEvent{}, fmt.Errorf("bad timestamp: %v", err)
	}
	if err := json.Unmarshal(raw[1], &ev.Stream); err != nil {
		return castEvent{}, fmt.Errorf("bad stream tag: %v", err)
	}
	if ev.Stream != "o" && ev.Stream != "e" {
		return castEvent{}, fmt.Errorf("unknown stream tag %q", ev.Stream)
	}
	var payload string
	if err := json.Unmarshal(raw[2], &payload); err != nil {
		return castEvent{}, fmt.Errorf("bad payload: %v", err)
	}
	encoding := "utf8"
	if len(raw) >= 4 {
		if err := json.Unmarshal(raw[3], &encoding); err != nil {
			return castEvent{}, fmt.Errorf("bad encoding tag: %v", err)
		}
	}
	switch encoding {
	case "utf8", "":
		ev.Data = []byte(payload)
	case "b64":
		dec, err := base64.StdEncoding.DecodeString(payload)
		if err != nil {
			return castEvent{}, fmt.Errorf("bad base64 payload: %v", err)
		}
		ev.Data = dec
	default:
		return castEvent{}, fmt.Errorf("unknown payload encoding %q", encoding)
	}
	return ev, nil
}

func loadCast(path string) (*cast, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 64*1024), maxCastLine)

	c := &cast{}
	gotHeader := false
	lineNo := 0
	for sc.Scan() {
		lineNo++
		line := bytes.TrimSpace(sc.Bytes())
		if len(line) == 0 {
			continue
		}
		if !gotHeader {
			if line[0] != '{' {
				return nil, fmt.Errorf("%s: line %d: expected a header object, got %q", path, lineNo, previewOf(line))
			}
			if err := json.Unmarshal(line, &c.Header); err != nil {
				return nil, fmt.Errorf("%s: line %d: malformed header: %v", path, lineNo, err)
			}
			if c.Header.Version != castVersion {
				return nil, fmt.Errorf("%s: unsupported cast version %d (this build understands version %d)", path, c.Header.Version, castVersion)
			}
			gotHeader = true
			continue
		}
		switch line[0] {
		case '[':
			ev, err := decodeEvent(line)
			if err != nil {
				return nil, fmt.Errorf("%s: line %d: %v", path, lineNo, err)
			}
			c.Events = append(c.Events, ev)
		case '{':
			var ft castFooter
			if err := json.Unmarshal(line, &ft); err != nil {
				return nil, fmt.Errorf("%s: line %d: malformed footer: %v", path, lineNo, err)
			}
			c.Footer = &ft
		default:
			return nil, fmt.Errorf("%s: line %d: unrecognised record %q", path, lineNo, previewOf(line))
		}
	}
	if err := sc.Err(); err != nil {
		if errors.Is(err, bufio.ErrTooLong) {
			return nil, fmt.Errorf("%s: line %d exceeds the %d byte limit", path, lineNo+1, maxCastLine)
		}
		return nil, fmt.Errorf("%s: %v", path, err)
	}
	if !gotHeader {
		return nil, fmt.Errorf("%s: no cast header found (file is empty?)", path)
	}
	return c, nil
}

func previewOf(line []byte) string {
	const n = 32
	s := string(line)
	if len(s) > n {
		s = s[:n] + "..."
	}
	return s
}

// duration is the timestamp of the final event, i.e. when the last byte landed.
func (c *cast) duration() float64 {
	if len(c.Events) == 0 {
		return 0
	}
	return c.Events[len(c.Events)-1].Time
}

func (c *cast) byteCounts() (out, errb int64) {
	for _, ev := range c.Events {
		if ev.Stream == "e" {
			errb += int64(len(ev.Data))
		} else {
			out += int64(len(ev.Data))
		}
	}
	return
}

func (c *cast) longestIdle() float64 {
	prev, longest := 0.0, 0.0
	for _, ev := range c.Events {
		if g := ev.Time - prev; g > longest {
			longest = g
		}
		prev = ev.Time
	}
	return longest
}

// ---------------------------------------------------------------------------
// Writer used while recording
// ---------------------------------------------------------------------------

type recorder struct {
	mu    sync.Mutex
	f     *os.File
	start time.Time
}

// emit stamps, mirrors and persists one chunk. The lock covers the timestamp,
// the live write and the file write together, so the recorded order always
// matches both the recorded timestamps and what the viewer saw live.
func (r *recorder) emit(stream string, data []byte, live io.Writer) {
	r.mu.Lock()
	defer r.mu.Unlock()
	t := time.Since(r.start).Seconds()
	if t < 0 {
		t = 0
	}
	_, _ = live.Write(data)
	line := encodeEvent(t, stream, data)
	line = append(line, '\n')
	_, _ = r.f.Write(line)
}

func (r *recorder) writeLine(b []byte) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	b = append(append([]byte{}, b...), '\n')
	_, err := r.f.Write(b)
	return err
}

func (r *recorder) pump(src io.Reader, stream string, live io.Writer) {
	buf := make([]byte, readChunk)
	for {
		n, err := src.Read(buf)
		if n > 0 {
			r.emit(stream, buf[:n], live)
		}
		if err != nil {
			return
		}
	}
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "help":
		printHelp(os.Stdout)
		os.Exit(0)
	case "version", "--version", "-V":
		fmt.Printf("%s %s\n", progName, progVersion)
		os.Exit(0)
	case "record":
		cmdRecord(args[1:])
	case "replay":
		cmdReplay(args[1:])
	case "info":
		cmdInfo(args[1:])
	case "cat":
		cmdCat(args[1:])
	default:
		fmt.Fprintf(os.Stderr, "%s: unknown command %q\n", progName, args[0])
		usage()
		os.Exit(1)
	}
}

func wantsHelp(args []string) bool {
	for _, a := range args {
		switch a {
		case "-h", "--help", "help":
			return true
		case "--":
			return false
		}
	}
	return false
}

func fail(format string, a ...any) {
	fmt.Fprintf(os.Stderr, progName+": "+format+"\n", a...)
	os.Exit(1)
}

func failUsage(format string, a ...any) {
	fmt.Fprintf(os.Stderr, progName+": "+format+"\n", a...)
	usage()
	os.Exit(1)
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	return fs
}

// --- record ----------------------------------------------------------------

func cmdRecord(argv []string) {
	if wantsHelp(argv) {
		printHelp(os.Stdout)
		os.Exit(0)
	}

	// The command after "--" is never flag-reordered: its own flags belong to it.
	sep := -1
	for i, a := range argv {
		if a == "--" {
			sep = i
			break
		}
	}
	if sep < 0 {
		failUsage("record needs -- before the command, e.g. record out.jsonl -- uptime")
	}
	own := reorderFlags(argv[:sep], map[string]bool{"title": true})
	command := argv[sep+1:]

	fs := newFlagSet("record")
	title := fs.String("title", "", "label stored in the cast header")
	if err := fs.Parse(own); err != nil {
		failUsage("record: %v", err)
	}
	rest := fs.Args()
	if len(rest) == 0 {
		failUsage("record: missing <cast.jsonl>")
	}
	if len(rest) > 1 {
		failUsage("record: unexpected argument %q (put the command after --)", rest[1])
	}
	if len(command) == 0 {
		failUsage("record: no command given after --")
	}
	castPath := rest[0]

	f, err := os.Create(castPath)
	if err != nil {
		fail("cannot create %s: %v", castPath, err)
	}
	rec := &recorder{f: f, start: time.Now()}

	hdr := castHeader{
		Version:   castVersion,
		Command:   command,
		StartedAt: time.Now().UTC().Format(time.RFC3339),
		Shell:     os.Getenv("SHELL"),
		Width:     envInt("COLUMNS", 80),
		Height:    envInt("LINES", 24),
		Title:     *title,
	}
	hdrLine, err := json.Marshal(hdr)
	if err != nil {
		fail("cannot encode cast header: %v", err)
	}
	if err := rec.writeLine(hdrLine); err != nil {
		fail("cannot write %s: %v", castPath, err)
	}

	cmd := exec.Command(command[0], command[1:]...)
	cmd.Stdin = os.Stdin
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		fail("cannot capture stdout: %v", err)
	}
	stderr, err := cmd.StderrPipe()
	if err != nil {
		fail("cannot capture stderr: %v", err)
	}

	rec.start = time.Now()
	if err := cmd.Start(); err != nil {
		finishCast(rec, castFooter{ExitCode: 127, Duration: time.Since(rec.start).Seconds()})
		f.Close()
		fmt.Fprintf(os.Stderr, "%s: cannot run %q: %v\n", progName, command[0], err)
		os.Exit(127)
	}

	// Forward Ctrl-C / SIGTERM to the child so the recording ends the same way a
	// real session would; a second signal escalates to a hard kill. Either way
	// the footer is still written, so the cast stays valid and replayable.
	var interrupted atomic.Bool
	sigCh := make(chan os.Signal, 4)
	signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
	stopSig := make(chan struct{})
	go func() {
		seen := 0
		for {
			select {
			case s := <-sigCh:
				interrupted.Store(true)
				seen++
				if seen == 1 {
					_ = cmd.Process.Signal(s)
				} else {
					_ = cmd.Process.Kill()
				}
			case <-stopSig:
				return
			}
		}
	}()

	var wg sync.WaitGroup
	wg.Add(2)
	go func() { defer wg.Done(); rec.pump(stdout, "o", os.Stdout) }()
	go func() { defer wg.Done(); rec.pump(stderr, "e", os.Stderr) }()
	wg.Wait()

	waitErr := cmd.Wait()
	close(stopSig)
	signal.Stop(sigCh)

	ft := castFooter{
		Duration:    time.Since(rec.start).Seconds(),
		Interrupted: interrupted.Load(),
	}
	exitStatus := 0
	if waitErr != nil {
		var ee *exec.ExitError
		if errors.As(waitErr, &ee) {
			ft.ExitCode = ee.ExitCode()
			exitStatus = ft.ExitCode
			if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
				ft.Signal = ws.Signal().String()
				exitStatus = 128 + int(ws.Signal())
			}
		} else {
			ft.ExitCode = 127
			exitStatus = 127
			fmt.Fprintf(os.Stderr, "%s: %v\n", progName, waitErr)
		}
	}
	if exitStatus < 0 || exitStatus > 255 {
		exitStatus = 1
	}
	finishCast(rec, ft)
	if err := f.Close(); err != nil {
		fail("cannot close %s: %v", castPath, err)
	}
	os.Exit(exitStatus)
}

func finishCast(rec *recorder, ft castFooter) {
	line, err := json.Marshal(ft)
	if err != nil {
		return
	}
	_ = rec.writeLine(line)
}

func envInt(name string, def int) int {
	if v, err := strconv.Atoi(os.Getenv(name)); err == nil && v > 0 {
		return v
	}
	return def
}

// --- replay ----------------------------------------------------------------

// dualWriter buffers stdout and stderr separately but flushes on every stream
// switch, so the byte order a reader sees when both are joined is the order the
// events were recorded in.
type dualWriter struct {
	out  *bufio.Writer
	errw *bufio.Writer
	last string
}

func newDualWriter() *dualWriter {
	return &dualWriter{out: bufio.NewWriter(os.Stdout), errw: bufio.NewWriter(os.Stderr)}
}

func (d *dualWriter) write(stream string, data []byte) {
	if stream != d.last {
		d.flush()
		d.last = stream
	}
	if stream == "e" {
		_, _ = d.errw.Write(data)
	} else {
		_, _ = d.out.Write(data)
	}
}

func (d *dualWriter) flush() {
	_ = d.out.Flush()
	_ = d.errw.Flush()
}

func cmdReplay(argv []string) {
	if wantsHelp(argv) {
		printHelp(os.Stdout)
		os.Exit(0)
	}
	argv = reorderFlags(argv, map[string]bool{"speed": true, "max-idle": true})
	fs := newFlagSet("replay")
	speed := fs.Float64("speed", 1.0, "replay speed multiplier")
	maxIdle := fs.Float64("max-idle", 0, "cap any single gap, in seconds (0 = no cap)")
	noTiming := fs.Bool("no-timing", false, "dump everything instantly")
	if err := fs.Parse(argv); err != nil {
		failUsage("replay: %v", err)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		failUsage("replay: expected exactly one <cast.jsonl>")
	}
	if *speed <= 0 {
		fail("replay: --speed must be greater than 0")
	}
	if *maxIdle < 0 {
		fail("replay: --max-idle cannot be negative")
	}

	c, err := loadCast(rest[0])
	if err != nil {
		fail("%v", err)
	}

	w := newDualWriter()
	defer w.flush()

	start := time.Now()
	virtual := 0.0
	prev := 0.0
	for _, ev := range c.Events {
		gap := ev.Time - prev
		if gap < 0 {
			gap = 0
		}
		if *maxIdle > 0 && gap > *maxIdle {
			gap = *maxIdle
		}
		virtual += gap / *speed
		if !*noTiming {
			target := start.Add(time.Duration(virtual * float64(time.Second)))
			if d := time.Until(target); d > 0 {
				time.Sleep(d)
			}
		}
		w.write(ev.Stream, ev.Data)
		if !*noTiming {
			w.flush()
		}
		prev = ev.Time
	}
	w.flush()
}

// --- info ------------------------------------------------------------------

type infoReport struct {
	File        string   `json:"file"`
	Version     int      `json:"version"`
	Command     []string `json:"command"`
	StartedAt   string   `json:"started_at"`
	Shell       string   `json:"shell"`
	Width       int      `json:"width"`
	Height      int      `json:"height"`
	Title       string   `json:"title"`
	Events      int      `json:"events"`
	Duration    float64  `json:"duration"`
	Bytes       int64    `json:"bytes"`
	StdoutBytes int64    `json:"stdout_bytes"`
	StderrBytes int64    `json:"stderr_bytes"`
	LongestIdle float64  `json:"longest_idle"`
	ExitCode    *int     `json:"exit_code"`
	Signal      string   `json:"signal,omitempty"`
	Interrupted bool     `json:"interrupted"`
	Complete    bool     `json:"complete"`
}

func cmdInfo(argv []string) {
	if wantsHelp(argv) {
		printHelp(os.Stdout)
		os.Exit(0)
	}
	argv = reorderFlags(argv, map[string]bool{})
	fs := newFlagSet("info")
	asJSON := fs.Bool("json", false, "emit machine-readable JSON")
	if err := fs.Parse(argv); err != nil {
		failUsage("info: %v", err)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		failUsage("info: expected exactly one <cast.jsonl>")
	}
	c, err := loadCast(rest[0])
	if err != nil {
		fail("%v", err)
	}

	outB, errB := c.byteCounts()
	rep := infoReport{
		File:        rest[0],
		Version:     c.Header.Version,
		Command:     c.Header.Command,
		StartedAt:   c.Header.StartedAt,
		Shell:       c.Header.Shell,
		Width:       c.Header.Width,
		Height:      c.Header.Height,
		Title:       c.Header.Title,
		Events:      len(c.Events),
		Duration:    c.duration(),
		Bytes:       outB + errB,
		StdoutBytes: outB,
		StderrBytes: errB,
		LongestIdle: c.longestIdle(),
		Complete:    c.Footer != nil,
	}
	if rep.Command == nil {
		rep.Command = []string{}
	}
	if c.Footer != nil {
		code := c.Footer.ExitCode
		rep.ExitCode = &code
		rep.Signal = c.Footer.Signal
		rep.Interrupted = c.Footer.Interrupted
	}

	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(rep); err != nil {
			fail("cannot write JSON: %v", err)
		}
		return
	}

	w := bufio.NewWriter(os.Stdout)
	defer w.Flush()
	title := rep.Title
	if title == "" {
		title = "(none)"
	}
	fmt.Fprintf(w, "file:         %s\n", rep.File)
	fmt.Fprintf(w, "title:        %s\n", title)
	fmt.Fprintf(w, "command:      %s\n", strings.Join(rep.Command, " "))
	fmt.Fprintf(w, "started at:   %s\n", rep.StartedAt)
	fmt.Fprintf(w, "shell:        %s\n", rep.Shell)
	fmt.Fprintf(w, "size:         %dx%d\n", rep.Width, rep.Height)
	fmt.Fprintf(w, "events:       %d\n", rep.Events)
	fmt.Fprintf(w, "duration:     %.3fs\n", rep.Duration)
	fmt.Fprintf(w, "total bytes:  %s (stdout %s, stderr %s)\n",
		humanBytes(rep.Bytes), humanBytes(rep.StdoutBytes), humanBytes(rep.StderrBytes))
	fmt.Fprintf(w, "longest idle: %.3fs\n", rep.LongestIdle)
	if rep.ExitCode == nil {
		fmt.Fprintf(w, "exit code:    unknown (cast has no footer - recording was cut short)\n")
	} else {
		extra := ""
		if rep.Signal != "" {
			extra = " (stopped by signal: " + rep.Signal + ")"
		} else if rep.Interrupted {
			extra = " (recording was interrupted)"
		}
		fmt.Fprintf(w, "exit code:    %d%s\n", *rep.ExitCode, extra)
	}
}

// --- cat -------------------------------------------------------------------

func cmdCat(argv []string) {
	if wantsHelp(argv) {
		printHelp(os.Stdout)
		os.Exit(0)
	}
	argv = reorderFlags(argv, map[string]bool{})
	fs := newFlagSet("cat")
	stdoutOnly := fs.Bool("stdout-only", false, "print only stdout events")
	stderrOnly := fs.Bool("stderr-only", false, "print only stderr events")
	if err := fs.Parse(argv); err != nil {
		failUsage("cat: %v", err)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		failUsage("cat: expected exactly one <cast.jsonl>")
	}
	if *stdoutOnly && *stderrOnly {
		fail("cat: --stdout-only and --stderr-only are mutually exclusive")
	}
	c, err := loadCast(rest[0])
	if err != nil {
		fail("%v", err)
	}
	w := bufio.NewWriter(os.Stdout)
	for _, ev := range c.Events {
		if *stdoutOnly && ev.Stream != "o" {
			continue
		}
		if *stderrOnly && ev.Stream != "e" {
			continue
		}
		if _, err := w.Write(ev.Data); err != nil {
			fail("cat: %v", err)
		}
	}
	if err := w.Flush(); err != nil {
		fail("cat: %v", err)
	}
}

// ---------------------------------------------------------------------------
// Help
// ---------------------------------------------------------------------------

const helpText = `sessionforge - record a command's output with timing, replay it later

USAGE
  sessionforge record <cast.jsonl> [--title TEXT] -- <command> [args...]
  sessionforge replay <cast.jsonl> [--speed 1.0] [--max-idle 2.0] [--no-timing]
  sessionforge info   <cast.jsonl> [--json]
  sessionforge cat    <cast.jsonl> [--stdout-only | --stderr-only]
  sessionforge help | version

RECORD
  Runs the command, streams its output to your terminal live, and writes every
  chunk to the cast file with the elapsed time it arrived at. stdout and stderr
  are tagged separately but stored interleaved in real time order. record is
  transparent: its own stdout and stderr carry only the child's bytes, and it
  exits with the child's exit status (128+N if the child was killed by signal N).
    --title TEXT   label stored in the cast header

REPLAY
  Replays the cast with the original inter-event delays.
    --speed N      divide every delay by N (2 = twice as fast). Default 1.0
    --max-idle S   cap any single gap at S seconds, so a five minute pause can
                   replay in two. Default 0, meaning no cap
    --no-timing    dump everything instantly, no sleeping

INFO
  Header fields, event count, duration, byte totals, longest idle gap and the
  recorded exit code. --json emits the same data as one JSON object.

CAT
  Plain transcript with no timing. Byte-identical to what the command printed.
    --stdout-only  only stdout events
    --stderr-only  only stderr events

CAST FORMAT
  JSON lines. Line 1 is a header object, then one array per event:
    [elapsed_seconds, "o"|"e", "chunk"]
  and a trailing footer object holding the exit code. See README.txt.

NOTE
  This records output streams, not a full interactive PTY session.
`

func printHelp(w io.Writer) {
	fmt.Fprint(w, helpText)
}

func usage() {
	printHelp(os.Stderr)
}
