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
+3
View File
@@ -0,0 +1,3 @@
claude_extract
*.db-shm
*.db-wal
+139
View File
@@ -0,0 +1,139 @@
---
name: claude_extract
lang: go
domain: infra
version: 0.1.0
description: "CLI que automatiza una TUI interactiva a traves de un pseudo-terminal (PTY) headless y captura su texto. Pensada para la CLI 'claude' (solo entra en modo interactivo con un TTY real) pero sirve para cualquier TUI. Reconstruye el layout 2D con un emulador VT, o limpia ANSI de output secuencial, y permite pipear el resultado a otro proceso."
tags: [cli, terminal, pty, tui, automation, capture]
uses_functions:
- pty_capture_idle_go_infra
- vt_render_go_tui
- strip_ansi_go_core
uses_types: []
framework: ""
entry_point: "main.go"
dir_path: "apps/claude_extract"
icon:
phosphor: "terminal-window"
accent: "#7c3aed"
e2e_checks:
- id: build
cmd: "CGO_ENABLED=1 go build -tags fts5 -o claude_extract ."
timeout_s: 120
- id: smoke_capture
cmd: "./claude_extract --cmd bash --arg -lc --arg 'echo CAPTURA_OK' --warmup 200ms --idle 400ms --max 5s"
expect_stdout_contains: "CAPTURA_OK"
timeout_s: 15
- id: smoke_screen_layout
cmd: "./claude_extract --cmd bash --arg -lc --arg $'printf \"foo\\033[10Gbar\\n\"' --mode screen --warmup 200ms --idle 400ms --max 5s"
expect_stdout_contains: "foo"
timeout_s: 15
- id: smoke_stream_strip
cmd: "./claude_extract --cmd bash --arg -lc --arg $'printf \"\\033[31mROJO\\033[0m\\n\"' --mode stream --warmup 200ms --idle 400ms --max 5s"
expect_stdout_contains: "ROJO"
timeout_s: 15
- id: smoke_exec_pipe
cmd: "./claude_extract --cmd bash --arg -lc --arg 'echo pipe ok' --warmup 200ms --idle 400ms --max 5s --exec 'tr a-z A-Z'"
expect_stdout_contains: "PIPE OK"
timeout_s: 15
---
# claude_extract
## Que hace
Automatiza una CLI interactiva (TUI) y captura su texto, de forma headless, a traves de un
pseudo-terminal (PTY). Nunca abre una ventana de terminal: el PTY es virtual, en memoria.
Existe porque algunas CLIs — sobre todo la CLI `claude` — solo entran en su modo interactivo
rico cuando detectan un TTY real. Un pipe normal las degrada a modo "print". `claude_extract`
le da al proceso hijo un PTY real, lo dirige con input scripteado (teclea el prompt, espera, y
pulsa Enter como pasos separados), espera a que el render se estabilice, y devuelve el texto.
Por defecto el texto se imprime limpio a stdout. Con `--exec` se pipea a otro proceso por
stdin. Con `--mode raw` se obtienen los bytes del terminal sin tocar.
## Arquitectura
La app es solo orquestacion + superficie de linea de comandos + defaults amables con `claude`.
Toda la logica reutilizable vive en el registry:
| Pieza | Funcion del registry | Rol |
|---|---|---|
| Captura PTY | `pty_capture_idle_go_infra` | Lanza el comando en un PTY, inyecta input, corta por inactividad o timeout, devuelve bytes crudos. |
| Render de pantalla | `vt_render_go_tui` | Emula un terminal VT100 y reconstruye el layout 2D (espacios entre columnas que en el stream eran movimientos de cursor). Modo `screen`. |
| Limpieza de stream | `strip_ansi_go_core` | Quita secuencias ANSI de output secuencial tipo log. Modo `stream`. |
## Modos de salida (`--mode`)
| Modo | Que hace | Cuando |
|---|---|---|
| `screen` (default) | Reconstruye el layout 2D con `vt_render`. | TUIs que posicionan texto con cursor absoluto: `claude`, `htop`, `dialog`. Sin esto las palabras quedan pegadas ("2newMCPservers"). |
| `stream` | Quita ANSI del stream con `strip_ansi`. | Output secuencial: logs, builds, comandos que imprimen linea a linea. |
| `raw` | Bytes del PTY intactos (ANSI incluido). | Cuando quieres procesar los escape codes tu mismo. Atajo: `--raw`. |
## Ejemplo
```bash
cd apps/claude_extract
CGO_ENABLED=1 go build -tags fts5 -o claude_extract .
# Preguntar a claude y obtener su respuesta como texto con layout (modo screen).
# --cwd apunta a un repo donde los MCP de claude ya estan aprobados, para saltar
# el dialogo de arranque "new MCP servers found".
./claude_extract \
--prompt "responde unicamente con la palabra PONG" \
--cwd /home/enmanuel/fn_registry \
--warmup 4s --step-delay 600ms --idle 4s --max 60s
# Capturar una TUI cualquiera (sin prompt), output secuencial limpio.
./claude_extract --cmd bash --arg -lc --arg 'echo hola' --mode stream
# Pipear el texto capturado a otro proceso por stdin.
./claude_extract --prompt "lista 5 ideas" --cwd /home/enmanuel/fn_registry --exec "tee ideas.txt"
# Leer el prompt de un pipe.
echo "explica este error" | ./claude_extract --cwd /home/enmanuel/fn_registry
```
## Flags
| Flag | Default | Que hace |
|---|---|---|
| `--cmd` | `claude` | Comando a lanzar dentro del PTY. |
| `--arg` | — | Argumento extra para `--cmd` (repetible). |
| `--prompt` | — | Texto que se teclea primero, seguido de Enter. Si vacio y stdin es un pipe, se lee de stdin. |
| `--send` | — | Input crudo extra tras el prompt (repetible). Incluye `\r` para Enter, ej. `--send $'\r'`. |
| `--mode` | `screen` | `screen` \| `stream` \| `raw`. Ver tabla de modos. |
| `--raw` | false | Atajo de `--mode raw`. |
| `--warmup` | `2.5s` | Espera antes de enviar input, para que la TUI cargue. |
| `--step-delay` | `300ms` | Espera entre inputs sucesivos (entre teclear y Enter). |
| `--idle` | `2.5s` | Corta la captura tras este silencio (sin bytes nuevos de la TUI). |
| `--max` | `120s` | Timeout duro de toda la captura. |
| `--exec` | — | Pipea el texto capturado al stdin de este comando (via `sh -c`). |
| `--out` | — | Tambien escribe el texto capturado a este archivo. |
| `--cwd` | — | Ejecuta el hijo en este directorio (util para saltar el dialogo MCP de claude). |
## Cuando usarla
- Cuando necesites el render real de una CLI interactiva como texto, para auditar, scriptear o
alimentar otro proceso.
- Cuando `claude -p` (modo print) no te sirva porque quieres exactamente lo que muestra la TUI.
## Gotchas
- **Linux/Unix only**: el PTY es POSIX (heredado de `pty_capture_idle_go_infra`).
- **Enter separado**: el prompt y el Enter se envian como pasos distintos a proposito; un `\r`
pegado al texto lo trata `claude` como newline literal en el input, no como submit.
- **Layout pegado en modo stream**: para TUIs con posicionamiento absoluto usa `--mode screen`.
`--mode stream` (strip_ansi) pega las palabras porque los espacios entre columnas eran
movimientos de cursor.
- **Spinners y el corte por idle**: si la TUI hace render periodico (spinner, reloj), el `--idle`
no se dispara y la captura cae al `--max`. Para `claude`, el spinner se detiene al terminar la
respuesta, asi que `--idle` corta poco despues; sube `--max` si la respuesta es larga.
- **Dialogo de arranque de claude**: en un cwd cuyos MCP no estan aprobados, claude muestra
"new MCP servers found" y bloquea. Usa `--cwd <repo-raiz-aprobado>` o despacha el dialogo con
`--send`.
- **Dimensiones fijas 40x120**: el PTY y el render usan 40 filas x 120 columnas. Una respuesta
mas ancha se envuelve a 120 columnas.
- **Sin color**: el modo `screen` reconstruye texto y layout, no color.
+45
View File
@@ -0,0 +1,45 @@
module claude_extract
go 1.25.0
require fn-registry v0.0.0
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
nhooyr.io/websocket v1.8.17 // indirect
)
replace fn-registry => ../../
+76
View File
@@ -0,0 +1,76 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
+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()
}
View File