// Command claude_extract automates an interactive terminal UI (TUI) and captures // its rendered text, headlessly, through a pseudo-terminal (PTY). // // It exists because some CLIs — most notably the `claude` CLI — only enter their // rich interactive mode when they detect a real TTY. A normal pipe makes them // fall back to a degraded "print" mode. claude_extract gives the child process a // real PTY (in memory, no window is ever opened), drives it with scripted input, // waits for the render to settle, and hands you back the text. // // By default the captured text is cleaned of ANSI escape sequences and printed to // stdout, so it composes with normal Unix pipes. With --exec you can instead pipe // the captured text straight into another process's stdin. With --raw you get the // untouched terminal bytes, escape codes included. // // The capture primitive (PTY spawn + idle-based cutoff) lives in the registry as // pty_capture_idle_go_infra; ANSI stripping lives in strip_ansi_go_core. This app // only orchestrates them and adds the command-line surface plus claude-friendly // defaults. package main import ( "context" "flag" "fmt" "os" "os/exec" "strings" "time" "fn-registry/functions/core" "fn-registry/functions/infra" "fn-registry/functions/tui" ) // PTY grid size. Must match the size pty_capture_idle_go_infra uses internally // (40x120) so that vt_render reconstructs the layout with the same wrapping. const ( ptyRows = 40 ptyCols = 120 ) // stringList collects a repeatable flag (e.g. --send) into a slice, preserving order. type stringList []string func (s *stringList) String() string { return strings.Join(*s, ",") } func (s *stringList) Set(v string) error { *s = append(*s, v) return nil } func main() { var ( cmdName = flag.String("cmd", "claude", "command to launch inside the PTY") prompt = flag.String("prompt", "", "prompt text sent first, followed by Enter. If empty and stdin is piped, it is read from stdin") warmup = flag.Duration("warmup", 2500*time.Millisecond, "wait before sending input, so the TUI can finish loading") idle = flag.Duration("idle", 2500*time.Millisecond, "stop capturing after this much silence (no new bytes from the TUI)") maxDur = flag.Duration("max", 120*time.Second, "hard timeout for the whole capture") stepDelay = flag.Duration("step-delay", 300*time.Millisecond, "delay between successive scripted inputs") mode = flag.String("mode", "screen", "output mode: screen (reconstruct 2D layout, best for TUIs), stream (strip ANSI from sequential output, best for logs), raw (untouched PTY bytes)") raw = flag.Bool("raw", false, "shortcut for --mode raw") execCmd = flag.String("exec", "", "pipe the captured text into this command's stdin instead of writing to stdout") out = flag.String("out", "", "also write the captured text to this file") cwd = flag.String("cwd", "", "run the child command in this working directory (e.g. a repo root where claude's MCP servers are already approved, to skip the startup dialog)") ) var sends stringList flag.Var(&sends, "send", "extra raw input to send after the prompt (repeatable). Include \\r for Enter, e.g. --send $'\\r'") var cmdArgs stringList flag.Var(&cmdArgs, "arg", "extra argument passed to --cmd (repeatable)") flag.Usage = func() { fmt.Fprintf(os.Stderr, `claude_extract — drive an interactive TUI through a PTY and capture its text. Usage: claude_extract [flags] Examples: # Ask claude something, get clean text on stdout claude_extract --prompt "resume el README en 3 lineas" # Capture the raw terminal render (ANSI codes intact) claude_extract --prompt "hola" --raw # Pipe the captured text into another process claude_extract --prompt "lista 5 ideas" --exec "tee ideas.txt" # Drive a different TUI: send a query to htop-like tool, give it time, capture claude_extract --cmd htop --warmup 1s --idle 800ms --max 5s # Read the prompt from a pipe echo "explica este error" | claude_extract Flags: `) flag.PrintDefaults() } flag.Parse() // Run the child in a specific directory if requested. Changing our own cwd is // safe (this process is single-shot) and the PTY child inherits it. if *cwd != "" { if cerr := os.Chdir(*cwd); cerr != nil { fmt.Fprintf(os.Stderr, "claude_extract: --cwd: %v\n", cerr) os.Exit(1) } } // Resolve the prompt: explicit flag wins, otherwise read piped stdin. promptText := *prompt if promptText == "" && stdinIsPiped() { data, err := os.ReadFile("/dev/stdin") if err == nil { promptText = strings.TrimRight(string(data), "\n") } } // Build the scripted input sequence. The prompt text and the Enter keypress are // sent as SEPARATE steps (with stepDelay between them) because many TUIs — the // claude CLI among them — treat a "\r" glued to the text as a literal newline in // the input box rather than a submit. Typing, settling, then Enter triggers send. var inputs []string if promptText != "" { inputs = append(inputs, promptText, "\r") } inputs = append(inputs, sends...) ctx, cancel := context.WithTimeout(context.Background(), *maxDur+10*time.Second) defer cancel() rawOut, err := infra.PTYCaptureIdle(ctx, *cmdName, cmdArgs, *warmup, inputs, *stepDelay, *idle, *maxDur) if err != nil { fmt.Fprintf(os.Stderr, "claude_extract: capture failed: %v\n", err) os.Exit(1) } outMode := *mode if *raw { outMode = "raw" } var text string switch outMode { case "screen": // Reconstruct the 2D screen layout — correct for TUIs that position text // with absolute cursor moves (claude, htop). Keeps inter-column spacing. text = tui.VTRender(rawOut, ptyRows, ptyCols) case "stream": // Strip ANSI from a sequential byte stream — correct for log-like output. text = core.StripANSI(rawOut) case "raw": text = rawOut default: fmt.Fprintf(os.Stderr, "claude_extract: unknown --mode %q (want screen|stream|raw)\n", outMode) os.Exit(2) } if *out != "" { if werr := os.WriteFile(*out, []byte(text), 0o644); werr != nil { fmt.Fprintf(os.Stderr, "claude_extract: write --out: %v\n", werr) os.Exit(1) } } if *execCmd != "" { if perr := pipeToProcess(*execCmd, text); perr != nil { fmt.Fprintf(os.Stderr, "claude_extract: --exec failed: %v\n", perr) os.Exit(1) } return } fmt.Print(text) } // stdinIsPiped reports whether stdin is connected to a pipe/file rather than a terminal. func stdinIsPiped() bool { info, err := os.Stdin.Stat() if err != nil { return false } return (info.Mode() & os.ModeCharDevice) == 0 } // pipeToProcess runs cmdline through `sh -c` and feeds text to its stdin, wiring // the child's stdout/stderr to ours. func pipeToProcess(cmdline, text string) error { c := exec.Command("sh", "-c", cmdline) c.Stdin = strings.NewReader(text) c.Stdout = os.Stdout c.Stderr = os.Stderr return c.Run() }