Files
julian 17c08e6392 chore: initial monorepo scaffold + WDS Phase 1+2 artifacts
- Nx 22.7 monorepo (pnpm 11.1, TypeScript 5.9, Node 24)
- apps/api: NestJS 11 (CJS conforme CODING-RULES.md PGD-DB-004)
- apps/web: React 19 + Vite 8 (ESM)
- libs/shared/api-interface: Zod contract base
- Docker Compose dev: Postgres 18, Valkey 8, MinIO, Mailpit
- WDS artifacts:
  - design-artifacts/A-Product-Brief/ (5 docs canônicos + 16 dialogs)
  - design-artifacts/B-Trigger-Map/ (hub + 4 personas + feature impact)
- Stack canon: STACK.md v2.2 + CODING-RULES.md v2.0 + brand.md
- AGENTS.md + README.md como entrada para devs/agentes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:34:20 +00:00

172 lines
5.5 KiB
Python

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# ///
"""Run claude interactively via PTY so the Skill tool is available.
In `claude -p` (print mode) the Skill tool is never offered — Claude handles
everything inline. Running `claude` in interactive mode activates the Skill
tool so dependency skills installed in .claude/skills/ can be properly invoked.
The PTY tricks claude into thinking it has a terminal (interactive mode) while
we capture its stream-json output programmatically.
Usage:
python3 pty_runner.py --prompt-file /path/to/prompt.txt \\
--output /path/to/transcript.jsonl \\
[--timeout 600]
python3 pty_runner.py --prompt "Run headless. ..." --output transcript.jsonl
"""
from __future__ import annotations
import argparse
import json
import os
import pty
import re
import select
import subprocess
import sys
import time
from pathlib import Path
ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])|\r")
# How long to wait for claude to initialize before sending the prompt.
# Claude loads skill registry, checks credentials, etc. on startup.
INIT_WAIT_S = 5.0
# How long to wait after the stream-json 'result' event before killing claude.
# Trailing tool-result output sometimes follows the result event.
POST_RESULT_S = 4.0
def _strip_ansi(text: str) -> str:
return ANSI_RE.sub("", text)
def run_interactive(prompt: str, output: Path, timeout: int = 600) -> None:
"""Spawn claude interactively via PTY, send one prompt, capture transcript."""
master, slave = pty.openpty()
proc = subprocess.Popen(
[
"claude",
"--output-format", "stream-json",
"--verbose",
"--dangerously-skip-permissions",
],
stdin=slave,
stdout=slave,
stderr=slave,
close_fds=True,
)
os.close(slave)
json_lines: list[str] = []
buf = b""
prompt_sent = False
done_at: float | None = None
start = time.time()
try:
while True:
elapsed = time.time() - start
if elapsed > timeout:
print(f"[pty_runner] timeout after {elapsed:.0f}s", file=sys.stderr)
break
if done_at is not None and (time.time() - done_at) > POST_RESULT_S:
break
# Short select so we stay responsive but don't spin.
r, _, _ = select.select([master], [], [], 0.3)
if r:
try:
chunk = os.read(master, 8192)
except OSError:
break # PTY closed — claude exited
buf += chunk
# Process all complete lines in buffer.
while b"\n" in buf:
raw, buf = buf.split(b"\n", 1)
line = _strip_ansi(raw.decode("utf-8", errors="replace")).strip()
if not line.startswith("{"):
continue
json_lines.append(line)
try:
obj = json.loads(line)
# 'result' marks end of a claude turn.
if obj.get("type") == "result" and done_at is None:
done_at = time.time()
print(
f"[pty_runner] result event at t={time.time()-start:.1f}s "
f"({len(json_lines)} lines so far)",
file=sys.stderr,
)
except json.JSONDecodeError:
pass
else:
# Silence window — send prompt once claude has had time to init.
if not prompt_sent and (time.time() - start) >= INIT_WAIT_S:
os.write(master, (prompt + "\n").encode())
prompt_sent = True
print(
f"[pty_runner] prompt sent at t={time.time()-start:.1f}s",
file=sys.stderr,
)
finally:
# Politely ask claude to exit, then hard-kill if needed.
try:
os.write(master, b"exit\n")
time.sleep(0.3)
except OSError:
pass
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
try:
os.close(master)
except OSError:
pass
output.parent.mkdir(parents=True, exist_ok=True)
content = "\n".join(json_lines) + ("\n" if json_lines else "")
output.write_text(content, encoding="utf-8")
print(
f"[pty_runner] wrote {len(json_lines)} transcript lines → {output}",
file=sys.stderr,
)
def main() -> int:
p = argparse.ArgumentParser(
description="Run claude interactively via PTY and capture stream-json transcript"
)
grp = p.add_mutually_exclusive_group(required=True)
grp.add_argument("--prompt", help="Prompt text")
grp.add_argument("--prompt-file", type=Path, help="File containing the prompt")
p.add_argument("--output", type=Path, required=True, help="Output .jsonl transcript file")
p.add_argument("--timeout", type=int, default=600, help="Hard timeout in seconds")
args = p.parse_args()
prompt = (
args.prompt_file.read_text(encoding="utf-8").strip()
if args.prompt_file
else args.prompt
)
run_interactive(prompt, args.output, args.timeout)
return 0
if __name__ == "__main__":
sys.exit(main())