feat: scaffold claude_extract — captura headless de TUI via PTY

App CLI que automatiza una TUI interactiva a traves de un pseudo-terminal y
captura su texto. Pensada para la CLI claude (solo interactiva con TTY real),
generica para cualquier TUI.

- Modo screen: reconstruye layout 2D con vt_render_go_tui (emulador VT100).
- Modo stream: limpia ANSI de output secuencial con strip_ansi_go_core.
- Modo raw: bytes del PTY intactos.
- --exec pipea el texto a otro proceso; --cwd salta el dialogo MCP de claude.

Captura via pty_capture_idle_go_infra. Validada end-to-end contra claude
(prompt enviado, respuesta capturada) y con 5 e2e_checks POSIX deterministas.
This commit is contained in:
agent
2026-06-03 22:28:06 +02:00
commit 697c523604
6 changed files with 457 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
// 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()
}