From 8d6078e99e35facd565f9789bcc7ec59778f478a Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 3 Jun 2026 22:52:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20scaffold=20claude=5Fpipe=20=E2=80=94=20?= =?UTF-8?q?claude=20-p=20equivalente=20parseando=20la=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 3 + app.md | 127 +++++++++++++++++++++++++++++++++ go.mod | 44 ++++++++++++ go.sum | 74 ++++++++++++++++++++ main.go | 163 +++++++++++++++++++++++++++++++++++++++++++ tests/fake_claude.sh | 22 ++++++ 6 files changed, 433 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 100755 tests/fake_claude.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95da96f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +claude_pipe +*.db-shm +*.db-wal diff --git a/app.md b/app.md new file mode 100644 index 0000000..0a896bd --- /dev/null +++ b/app.md @@ -0,0 +1,127 @@ +--- +name: claude_pipe +lang: go +domain: infra +version: 0.1.0 +description: "Alternativa a 'claude -p' que obtiene la respuesta de claude como dato PARSEANDO su TUI interactiva: captura el render via PTY, reconstruye el layout y extrae los turnos + la respuesta final. Emite JSON con el mismo shape que 'claude -p --output-format json'. Para interaccion programatica robusta prefiere el camino stream-json (claude_stream_go_core); esta app es la variante que va a traves de la TUI." +tags: [cli, claude, terminal, pty, tui, parser] +uses_functions: + - pty_capture_idle_go_infra + - vt_render_go_tui + - parse_claude_tui_go_tui +uses_types: + - claude_tui_parse_go_tui + - claude_turn_go_tui +framework: "" +entry_point: "main.go" +dir_path: "apps/claude_pipe" +icon: + phosphor: "faders" + accent: "#0ea5e9" +e2e_checks: + - id: build + cmd: "CGO_ENABLED=1 go build -tags fts5 -o claude_pipe ." + timeout_s: 120 + - id: smoke_fake_text + cmd: "./claude_pipe --bin ./tests/fake_claude.sh --warmup 300ms --step-delay 100ms --idle 700ms --max 5s --format text test" + expect_stdout_contains: "RESPUESTA_FAKE_OK" + timeout_s: 15 + - id: smoke_fake_json + cmd: "./claude_pipe --bin ./tests/fake_claude.sh --warmup 300ms --step-delay 100ms --idle 700ms --max 5s --format json test" + expect_stdout_contains: "\"result\":\"RESPUESTA_FAKE_OK\"" + timeout_s: 15 +--- + +# claude_pipe + +## Que hace + +Devuelve la respuesta de `claude` como dato **parseando su TUI interactiva**, en lugar de usar +`claude -p`. Internamente lanza el `claude` interactivo dentro de un pseudo-terminal, captura el +render de la pantalla, reconstruye el layout 2D, y extrae los turnos de la conversacion mas la +respuesta final del asistente. La salida por defecto imita `claude -p --output-format json`. + +Es la respuesta a "quiero parsear la TUI y que devuelva lo mismo que `claude -p`". Para la mayoria +de los casos programaticos el camino limpio es `stream-json` (ver `claude_stream_go_core`), que no +toca la TUI; `claude_pipe` existe para cuando se quiere expresamente ir a traves de la TUI. + +## Arquitectura (composicion de funciones del registry) + +``` +claude (TUI) ─pty_capture_idle_go_infra─▶ bytes crudos del terminal + ─vt_render_go_tui──────────▶ pantalla como texto plano (layout 2D) + ─parse_claude_tui_go_tui───▶ ClaudeTUIParse {Turns, Answer} + ─(esta app)────────────────▶ JSON estilo claude -p / texto / turns +``` + +La app no aporta logica reutilizable: solo orquesta las tres funciones y elige el formato de salida. + +## Formatos de salida (`--format`) + +| Formato | Salida | Equivale a | +|---|---|---| +| `json` (default) | `{"type":"result","subtype":"success","is_error":false,"result":""}` | `claude -p --output-format json` (campos esenciales) | +| `text` | solo el texto de la respuesta | `claude -p` plano | +| `turns` | el `ClaudeTUIParse` completo: cada turno visible (user/assistant/tool_use/tool_result) + answer | — (mas rico que `claude -p`) | +| `screen` | el render de la pantalla sin parsear | debug | + +## Ejemplo + +```bash +cd apps/claude_pipe +CGO_ENABLED=1 go build -tags fts5 -o claude_pipe . + +# Respuesta como JSON (mismo shape que claude -p --output-format json) +./claude_pipe --cwd /home/enmanuel/fn_registry "responde unicamente con la palabra PONG" +# {"type":"result","subtype":"success","is_error":false,"result":"PONG"} + +# Solo el texto +./claude_pipe --format text --cwd /home/enmanuel/fn_registry "resume el README en 3 lineas" + +# Todos los turnos visibles (incluye tool_use/tool_result que la TUI muestra) +./claude_pipe --format turns --cwd /home/enmanuel/fn_registry "lee main.go y resumelo" + +# Prompt por stdin +echo "explica este error" | ./claude_pipe --cwd /home/enmanuel/fn_registry +``` + +## Flags + +| Flag | Default | Que hace | +|---|---|---| +| `--prompt` | — | Prompt a enviar. Si vacio, se toma del arg posicional o de stdin. | +| `--format` | `json` | `json` \| `text` \| `turns` \| `screen`. | +| `--cwd` | — | Directorio donde se lanza claude (usa un repo con los MCP aprobados, para saltar el dialogo de arranque). | +| `--bin` | `claude` | Binario claude a lanzar (o un fake para tests). | +| `--warmup` | `4s` | Espera antes de enviar el prompt, para que la TUI cargue. | +| `--step-delay` | `600ms` | Espera entre teclear el prompt y pulsar Enter. | +| `--idle` | `4s` | Corta la captura tras este silencio (respuesta terminada de renderizar). | +| `--max` | `120s` | Timeout duro de toda la captura. | + +## Cuando usarla + +- Cuando quieras la respuesta de una sesion `claude` como dato yendo **a traves de la TUI** + (no via `claude -p`). +- Para auditar/scriptear lo que la TUI muestra y obtener ademas los `tool_use`/`tool_result` + visibles (`--format turns`). + +Si no necesitas pasar por la TUI, usa `claude_stream_go_core` (stream-json) — es mas robusto. + +## Gotchas + +- **No reemplaza 1:1 a `claude -p`**: el campo `result` (la respuesta) coincide, pero NO se + extraen los metadatos (`cost`, `tokens`, `session_id`, `duration_ms`) porque la TUI no los + expone de forma fiable. +- **Truncacion por scroll**: la captura solo ve el grid visible (40x120). Una respuesta mas larga + que la pantalla se trunca por arriba (el render no guarda scrollback). Respuestas cortas/medias + van bien. +- **Heuristico y version-dependiente**: el parser asume el layout actual de la TUI de claude. Si + claude cambia su UI, hay que ajustar `parse_claude_tui_go_tui`. +- **Dialogo de arranque**: en un cwd cuyos MCP no estan aprobados, claude bloquea con "new MCP + servers found". Usa `--cwd `. +- **Latencia**: anade `warmup` + `idle` (por defecto ~8s de overhead) sobre el tiempo de respuesta + de claude. `claude -p` no tiene ese overhead. Es el precio de ir por la TUI. +- **Linux/Unix only**: hereda el PTY POSIX de `pty_capture_idle_go_infra`. +- **Streaming**: esta version es one-shot (espera la respuesta completa y luego parsea). El + streaming incremental de la TUI esta planificado como fase 2 (requiere capturar snapshots + durante el render). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dad0f0b --- /dev/null +++ b/go.mod @@ -0,0 +1,44 @@ +module claude_pipe + +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/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..959e463 --- /dev/null +++ b/go.sum @@ -0,0 +1,74 @@ +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/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..5214203 --- /dev/null +++ b/main.go @@ -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":""} +// (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 +} diff --git a/tests/fake_claude.sh b/tests/fake_claude.sh new file mode 100755 index 0000000..5dc1e40 --- /dev/null +++ b/tests/fake_claude.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Fake claude TUI used by claude_pipe's deterministic e2e checks. +# +# It does NOT talk to any model. It ignores stdin (the prompt claude_pipe types) +# and prints a minimal screen that mimics the markers parse_claude_tui_go_tui +# looks for: a "❯ " user line and a "● " assistant line. After printing it idles +# briefly so the capture's idle-cutoff fires and the process exits cleanly. +# +# stty -echo is essential: claude_pipe types the prompt into the PTY, and a TTY +# echoes input by default. The real claude TUI captures keystrokes into its own +# widget instead of echoing them; this fake has no such widget, so without +# disabling echo the typed prompt would leak onto the screen and pollute the +# parsed answer. The real claude binary is unaffected by this. +# +# This lets us validate the full capture -> render -> parse pipeline without +# spending a real claude call or depending on network/model output. + +stty -echo 2>/dev/null || true + +printf '\xe2\x9d\xaf test prompt\n\n' # "❯ test prompt" +printf '\xe2\x97\x8f RESPUESTA_FAKE_OK\n\n' # "● RESPUESTA_FAKE_OK" +sleep 2