# AGENTS.md — claude-print ## Repo purpose `claude-print` is a drop-in replacement for `claude -p` that drives the Claude Code interactive TUI via PTY, preserving subscription billing after the June 15, 2026 `cc_entrypoint` split. It spawns `claude` in a pseudo-terminal, auto-dismisses the trust dialog, injects the user's prompt, waits for the Stop hook, reads the transcript, and emits clean output — all without `--print` or `--output-format`. ## Build commands ```bash # Debug build cargo build # Musl release (static binary for deployment) cargo build --target x86_64-unknown-linux-musl --release # Tests (intercepted by ~/.local/bin/cargo — submits to iad-ci when repo is clean) cargo test # Unit tests only (no binary compilation required) cargo test --lib # Integration tests (requires compiled binary) cargo test --test '*' # Smoke check (verifies PTY, hooks, and environment prerequisites) ./target/debug/claude-print --check ``` The `cargo` wrapper at `~/.local/bin/cargo` auto-submits to the `rust-verify` WorkflowTemplate on `iad-ci` when there are no uncommitted changes and the repo has a remote. It falls back to a cgroup-limited local run otherwise. ## Test structure | Location | What it tests | |----------|---------------| | `src/*.rs` inline (`#[cfg(test)]`) | Unit tests — pure logic, no I/O | | `tests/integration.rs` | High-level integration; uses `mock_claude` | | `tests/integration/` | Sub-module helpers for integration tests | | `tests/cli.rs` | CLI argument parsing and flag validation | | `tests/emitter.rs` | Output formatting (text / json / stream-json) | | `tests/startup.rs` | Trust-dialog detection and prompt injection | | `tests/terminal.rs` | Terminal probe parsing | | `tests/transcript.rs` | JSONL transcript parsing | | `tests/hooks.rs` | Stop hook FIFO install / read | | `tests/stop_poller.rs` | Stop payload polling logic | | `tests/pty_integration.rs` | PTY spawn + round-trip (requires PTY capability) | | `tests/version_compat.rs` | `--version` output parsing | | `tests/fixtures/` | Shared fixture helpers | ### mock_claude `test-fixtures/mock-claude/` is a workspace member compiled as a separate binary. It impersonates `claude` for integration tests and is controlled via environment variables (see its own `README` / source). No real credentials are needed. To rebuild mock_claude explicitly: ```bash cargo build -p mock-claude ``` ## Module map | File | Role | |------|------| | `src/lib.rs` | Crate root — re-exports public modules for integration tests | | `src/main.rs` | Entry point: CLI parse, claude binary resolution, calls `session::Session::run()` | | `src/cli.rs` | Clap argument definitions (`Cli`, `OutputFormat`) | | `src/config.rs` | Loads `~/.claude/claude-print.toml` (model default, etc.) | | `src/session.rs` | Session orchestrator: installs hooks, spawns PTY child, runs event loop, reads transcript. `Session::run()` is the top-level entry point for a single prompt→response cycle. | | `src/pty.rs` | Forks child, opens PTY pair, calls `login_tty`, unsets `CLAUDE_CODE_SESSION_ID` in child, forwards SIGWINCH/SIGINT | | `src/startup.rs` | State machine: reads PTY output until trust dialog or idle; auto-dismisses (sends CR), injects prompt via bracketed paste; hard timeout after 45s with <200 bytes | | `src/event_loop.rs` | Single-threaded `poll(2)` loop (50ms timeout for timer ticks) over PTY master + self-pipe + stop FIFO; calls callback on each chunk | | `src/hook.rs` | Installs Stop hook via temp dir settings.json; creates FIFO; cleans up on drop | | `src/poller.rs` | Opens FIFO non-blocking (read + keeper write ends), parses Stop hook payload, derives transcript path from session_id + cwd | | `src/transcript.rs` | Reads `.jsonl` transcript; extracts last assistant message + token usage | | `src/emitter.rs` | Formats and writes output (`text`, `json`, `stream-json`) | | `src/terminal.rs` | Absorbs and discards terminal probe sequences (DA1/DA2/DSR/xtversion) from Ink TUI | | `src/error.rs` | `Error` enum and `Result` alias | | `src/check.rs` | `--check` mode: verifies PTY, FIFO, hooks, and `cc_entrypoint` env | ## Key invariants These must hold across all changes: 1. **Do not set `CLAUDE_CONFIG_DIR`** — transcripts must land in `~/.claude/projects/` (the real config dir). The temp dir is only used for the Stop hook settings injection, and it must not redirect the config dir. 2. **Clean up the temp dir on all exit paths** — no `claude-print--*` directories may be left in `$TMPDIR`. The `TempDir` handle in `HookInstaller` must remain owned until after the child exits. 3. **Forward SIGINT to the child process** — pressing Ctrl-C must reach `claude`, not just terminate `claude-print`. 4. **Never pass `--print` or `--output-format` to the child** — those flags activate the API billing path. The entire point is to stay on the PTY/TUI path. 5. **`cc_entrypoint=cli` is the correctness invariant** — verify that `CLAUDE_CC_ENTRYPOINT` (or equivalent) is `cli` via `--check` before each release. AS-4 in the plan documents the acceptance criterion. 6. **Unset `CLAUDE_CODE_SESSION_ID` in child** — the child must not inherit the parent's session ID or it will write events into the parent's transcript and may skip Stop hook dispatch. Only `CLAUDE_CODE_SESSION_ID` is unset; `CLAUDECODE=1` and `CLAUDE_CODE_ENTRYPOINT=cli` must be preserved. 7. **Keep both FIFO ends alive for the full event loop** — `open_fifo_nonblock()` returns `(read_fd, keeper_write_fd)`. Both must be stored until after the event loop exits. Dropping `read_fd` closes the fd the event loop is polling; dropping `keeper` causes `ENXIO` when the hook writes to the FIFO. ## Key implementation notes - **Event loop ticks on empty slices** — the event loop uses `poll(50ms)` (not blocking) and emits an empty-slice tick to the callback on timeout. The callback must guard `startup.feed()` and `terminal.feed()` from empty slices — feeding empty data resets the idle timer in `StartupSeq`. - **Timeout thread is detached, not joined** — `session.rs` detaches the timeout thread via `drop()` instead of joining it. The timeout thread sleeps for `cli.timeout` seconds (default 3600); joining before handling the exit reason would block the main thread for the full duration on early exit. - **Child cleanup uses `kill_child(pid)`** — `kill_child(pid)` sends SIGTERM, waits up to 2s, then SIGKILL. Use this for all child cleanup paths, not bare `waitpid`. ## Bead workflow Beads use the `bf` prefix. Config is at `.beads/config.yaml`. ```bash # List open beads br list # Claim a bead br claim # Close a bead (requires a commit first) br close ``` See `CLAUDE.md` (root workspace) for full `br` CLI docs and FrankenSQLite recovery procedures.