feat: scaffold claude_pipe — claude -p equivalente parseando la TUI

App que obtiene la respuesta de claude como dato parseando su TUI interactiva,
en lugar de usar claude -p. Compone tres funciones del registry:

- pty_capture_idle_go_infra: captura el render de la TUI via PTY headless.
- vt_render_go_tui: reconstruye el layout 2D como texto plano.
- parse_claude_tui_go_tui: extrae los turnos + la respuesta final.

Salida por defecto con el mismo shape que claude -p --output-format json.
Formatos: json, text, turns, screen. Validada end-to-end: el campo result
coincide exacto con claude -p nativo (PONG == PONG). e2e_checks deterministas
con un fake TUI (tests/fake_claude.sh) que no gasta llamadas reales.

Fase 1 (one-shot). El streaming incremental queda como fase 2.
This commit is contained in:
agent
2026-06-03 22:52:48 +02:00
commit 8d6078e99e
6 changed files with 433 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
// Command claude_pipe is a drop-in-ish replacement for `claude -p` that works by
// driving the interactive `claude` TUI through a pseudo-terminal, capturing its
// rendered screen, and parsing it back into structured data — the assistant's
// answer plus the visible conversation turns.
//
// It exists for the (unusual) case where you want the result of an interactive
// claude session as data, going THROUGH the TUI rather than through
// `claude -p --output-format json`. For most programmatic use the stream-json path
// (claude_stream_go_core) is cleaner and more robust; claude_pipe is the TUI-parsing
// alternative, kept because the TUI exposes things `-p` does not.
//
// Pipeline (all registry functions):
//
// pty_capture_idle_go_infra -> capture the TUI render headlessly via PTY
// vt_render_go_tui -> reconstruct the 2D screen as plain text
// parse_claude_tui_go_tui -> extract turns + final answer
//
// Output formats:
//
// --format json {"type":"result","subtype":"success","is_error":false,"result":"<answer>"}
// (mirrors `claude -p --output-format json`)
// --format text just the answer text (mirrors plain `claude -p`)
// --format turns the full ClaudeTUIParse (every visible turn + answer) as JSON
// --format screen debug: the raw rendered screen before parsing
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"context"
"fn-registry/functions/infra"
"fn-registry/functions/tui"
)
// PTY grid size. Must match what pty_capture_idle_go_infra uses internally (40x120)
// so vt_render reconstructs the layout with the same wrapping.
const (
ptyRows = 40
ptyCols = 120
)
// claudePResult mirrors the shape of `claude -p --output-format json`.
type claudePResult struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
IsError bool `json:"is_error"`
Result string `json:"result"`
}
func main() {
var (
prompt = flag.String("prompt", "", "prompt to send. If empty, taken from the positional arg, or from piped stdin")
format = flag.String("format", "json", "output format: json (like claude -p --output-format json), text (just the answer), turns (full parse), screen (debug: raw render)")
cwd = flag.String("cwd", "", "run claude in this directory (use a repo root whose MCP servers are approved, to skip the startup dialog)")
bin = flag.String("bin", "claude", "claude binary to launch")
warmup = flag.Duration("warmup", 4*time.Second, "wait before sending the prompt, so the TUI finishes loading")
stepDelay = flag.Duration("step-delay", 600*time.Millisecond, "delay between typing the prompt and pressing Enter")
idle = flag.Duration("idle", 4*time.Second, "stop capturing after this much silence (response finished rendering)")
maxDur = flag.Duration("max", 120*time.Second, "hard timeout for the whole capture")
)
flag.Usage = func() {
fmt.Fprintf(os.Stderr, `claude_pipe — get a claude answer as data by parsing its TUI (alternative to claude -p).
Usage:
claude_pipe [flags] [prompt]
Examples:
claude_pipe --cwd /home/enmanuel/fn_registry "responde solo PONG"
claude_pipe --format text --cwd /repo "resume el README en 3 lineas"
echo "explica este error" | claude_pipe --cwd /repo
claude_pipe --format turns --cwd /repo "lee main.go y resume" # incluye tool_use/tool_result visibles
Flags:
`)
flag.PrintDefaults()
}
flag.Parse()
if *cwd != "" {
if err := os.Chdir(*cwd); err != nil {
fmt.Fprintf(os.Stderr, "claude_pipe: --cwd: %v\n", err)
os.Exit(1)
}
}
promptText := *prompt
if promptText == "" && flag.NArg() > 0 {
promptText = strings.Join(flag.Args(), " ")
}
if promptText == "" && stdinIsPiped() {
if data, err := os.ReadFile("/dev/stdin"); err == nil {
promptText = strings.TrimRight(string(data), "\n")
}
}
if promptText == "" {
fmt.Fprintln(os.Stderr, "claude_pipe: no prompt (use --prompt, a positional arg, or pipe stdin)")
os.Exit(2)
}
// Type the prompt and press Enter as SEPARATE steps: a "\r" glued to the text is
// treated by claude as a literal newline in the input box, not a submit.
inputs := []string{promptText, "\r"}
ctx, cancel := context.WithTimeout(context.Background(), *maxDur+10*time.Second)
defer cancel()
raw, err := infra.PTYCaptureIdle(ctx, *bin, nil, *warmup, inputs, *stepDelay, *idle, *maxDur)
if err != nil {
fmt.Fprintf(os.Stderr, "claude_pipe: capture failed: %v\n", err)
os.Exit(1)
}
screen := tui.VTRender(raw, ptyRows, ptyCols)
if *format == "screen" {
fmt.Println(screen)
return
}
parsed := tui.ParseClaudeTUI(screen)
switch *format {
case "text":
fmt.Println(parsed.Answer)
case "turns":
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(parsed); err != nil {
fmt.Fprintf(os.Stderr, "claude_pipe: encode: %v\n", err)
os.Exit(1)
}
case "json":
res := claudePResult{
Type: "result",
Subtype: "success",
IsError: parsed.Answer == "",
Result: parsed.Answer,
}
enc := json.NewEncoder(os.Stdout)
if err := enc.Encode(res); err != nil {
fmt.Fprintf(os.Stderr, "claude_pipe: encode: %v\n", err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "claude_pipe: unknown --format %q (want json|text|turns|screen)\n", *format)
os.Exit(2)
}
}
// 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
}