From 697c523604243782f71116dc0b7a004fd6fdc159 Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 3 Jun 2026 22:28:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20scaffold=20claude=5Fextract=20=E2=80=94?= =?UTF-8?q?=20captura=20headless=20de=20TUI=20via=20PTY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 3 + app.md | 139 +++++++++++++++++++++++++++++++++++++ go.mod | 45 ++++++++++++ go.sum | 76 ++++++++++++++++++++ main.go | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++ registry.db | 0 6 files changed, 457 insertions(+) create mode 100644 .gitignore create mode 100644 app.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 registry.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1264d2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +claude_extract +*.db-shm +*.db-wal diff --git a/app.md b/app.md new file mode 100644 index 0000000..34ae659 --- /dev/null +++ b/app.md @@ -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 ` 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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5f59b01 --- /dev/null +++ b/go.mod @@ -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 => ../../ diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f58bb36 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..350e381 --- /dev/null +++ b/main.go @@ -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() +} diff --git a/registry.db b/registry.db new file mode 100644 index 0000000..e69de29