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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user