6.7 KiB
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
# 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:
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:
-
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. -
Clean up the temp dir on all exit paths — no
claude-print-<pid>-*directories may be left in$TMPDIR. TheTempDirhandle inHookInstallermust remain owned until after the child exits. -
Forward SIGINT to the child process — pressing Ctrl-C must reach
claude, not just terminateclaude-print. -
Never pass
--printor--output-formatto the child — those flags activate the API billing path. The entire point is to stay on the PTY/TUI path. -
cc_entrypoint=cliis the correctness invariant — verify thatCLAUDE_CC_ENTRYPOINT(or equivalent) isclivia--checkbefore each release. AS-4 in the plan documents the acceptance criterion. -
Unset
CLAUDE_CODE_SESSION_IDin 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. OnlyCLAUDE_CODE_SESSION_IDis unset;CLAUDECODE=1andCLAUDE_CODE_ENTRYPOINT=climust be preserved. -
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. Droppingread_fdcloses the fd the event loop is polling; droppingkeepercausesENXIOwhen 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 guardstartup.feed()andterminal.feed()from empty slices — feeding empty data resets the idle timer inStartupSeq. -
Timeout thread is detached, not joined —
session.rsdetaches the timeout thread viadrop()instead of joining it. The timeout thread sleeps forcli.timeoutseconds (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 barewaitpid.
Bead workflow
Beads use the bf prefix. Config is at .beads/config.yaml.
# List open beads
br list
# Claim a bead
br claim <id>
# Close a bead (requires a commit first)
br close <id>
See CLAUDE.md (root workspace) for full br CLI docs and FrankenSQLite recovery
procedures.