diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 5dc415c..9dc031b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,6 +1,6 @@ -{"id":"bf-10t","title":"Phase 10: Tests (~500 LOC)","description":"Entry: Phase 8 complete (can run in parallel with Phase 9).\n\nPhase 10 completes the test suite by adding tests NOT already written as part of Phases 2-9 completion criteria. Each prior phase's completion criterion already specifies and runs its own targeted integration tests.\n\nPhase 10 adds the remaining cross-phase and corner-case tests:\n- Version-resilience suite: feed transcript JSONL with extra/unknown fields across all JSONL event types; confirm no panic, correct output\n- Hook inheritance suite: verify user hooks in ~/.claude/settings.json fire alongside the relay hook (OQ-2 resolution validated end-to-end)\n- All MEDIUM/LOW mock scenarios not covered by earlier phases (see Testing section of plan.md for full list)\n- Conformance harness: run claude-print against a real claude binary in a sandboxed invocation and compare output format to claude -p reference\n\nComplete when:\n- cargo test passes with zero failures (all unit + integration tests)\n\nReference: docs/plan/plan.md § Phase 10","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:17.744030380Z","updated_at":"2026-06-10T03:54:17.744030380Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-10t","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.710642868Z","created_by":"cli","thread_id":""}]} +{"id":"bf-10t","title":"Phase 10: Tests (~500 LOC)","description":"Entry: Phase 8 complete (can run in parallel with Phase 9).\n\nPhase 10 completes the test suite by adding tests NOT already written as part of Phases 2-9 completion criteria. Each prior phase's completion criterion already specifies and runs its own targeted integration tests.\n\nPhase 10 adds the remaining cross-phase and corner-case tests:\n- Version-resilience suite: feed transcript JSONL with extra/unknown fields across all JSONL event types; confirm no panic, correct output\n- Hook inheritance suite: verify user hooks in ~/.claude/settings.json fire alongside the relay hook (OQ-2 resolution validated end-to-end)\n- All MEDIUM/LOW mock scenarios not covered by earlier phases (see Testing section of plan.md for full list)\n- Conformance harness: run claude-print against a real claude binary in a sandboxed invocation and compare output format to claude -p reference\n\nComplete when:\n- cargo test passes with zero failures (all unit + integration tests)\n\nReference: docs/plan/plan.md § Phase 10","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-glm-glm47-alpha","created_at":"2026-06-10T03:54:17.744030380Z","updated_at":"2026-06-10T05:31:03.149051175Z","source_repo":".","compaction_level":0,"labels":["deferred","failure-count:1"],"dependencies":[{"issue_id":"bf-10t","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.710642868Z","created_by":"cli","thread_id":""}]} {"id":"bf-2f1","title":"Phase 8: Emitter (~120 LOC)","description":"Entry: Phase 7 complete.\n\nImplementation: src/emitter.rs\n- Output format routing: text (plain string to stdout), json (JSON object with result/cost/session_id/claude_version), stream-json (forward raw JSONL lines as written)\n- claude_version: read from child process version string captured at startup\n- Error result objects: exit code mapping (0=success, 1=error, 2=no-response, 3=timeout, 4=input-error)\n- stream-json: reader thread consuming JSONL transcript file via mpsc channel, forward each line to stdout as written\n\nComplete when:\n- All emitter unit tests pass (tests/emitter.rs)\n- AS-1 (text output format) passes: response text emitted to stdout, exit 0\n- AS-2 (json output format) passes: valid JSON object on stdout with result field\n- stream-json output parses as valid JSONL (each line is valid JSON)\n\nReference: docs/plan/plan.md § Phase 8","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":1,"issue_type":"task","assignee":"claude-code-glm-47-claude-print-bravo","created_at":"2026-06-10T03:53:59.176338208Z","updated_at":"2026-06-10T05:45:00Z","closed_at":"2026-06-10T05:45:00Z","close_reason":"Completed","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-2f1","depends_on_id":"bf-64k","type":"blocks","created_at":"2026-06-10T03:54:31.701710128Z","created_by":"cli","thread_id":""}]} -{"id":"bf-42j","title":"Phase 9: NEEDLE Integration (~50 LOC + config)","description":"Entry: Phase 8 complete.\n\nDeliverables:\n1. claude-print.yaml — NEEDLE agent config for dispatching beads via claude-print instead of claude -p\n - input_method: stdin, output_transform: needle-transform-claude\n - invoke_template: claude-print --output-format json --model ${MODEL}\n2. install.sh — download release binary from GitHub, install to ~/.local/bin/claude-print, verify --check\n3. claude-print-ci WorkflowTemplate in jedarden/declarative-config (k8s/iad-ci/argo-workflows/)\n - verify step only (build-musl + github-release steps added in Phase 11)\n - delegates to rust-verify WorkflowTemplate\n4. --check subcommand in src/main.rs or src/check.rs\n - openpty probe: confirm openpty syscall succeeds\n - mkfifo probe: confirm mkfifo in /home/coding/.tmp succeeds\n - optional mock_claude PTY round-trip (if mock_claude binary present in PATH)\n - exits 0 on success, prints diagnostic table\n\nComplete when:\n- bash -n install.sh passes (syntactically valid)\n- Manually copying locally-built binary to ~/.local/bin/claude-print and running claude-print --check succeeds\n- NEEDLE dispatches a test bead using claude-print.yaml; AS-3 passes\n- README flags table matches claude-print --help output exactly (verified manually)\n\nReference: docs/plan/plan.md § Phase 9","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:09.318382701Z","updated_at":"2026-06-10T03:54:09.318382701Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-42j","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.706113490Z","created_by":"cli","thread_id":""}]} +{"id":"bf-42j","title":"Phase 9: NEEDLE Integration (~50 LOC + config)","description":"Entry: Phase 8 complete.\n\nDeliverables:\n1. claude-print.yaml — NEEDLE agent config for dispatching beads via claude-print instead of claude -p\n - input_method: stdin, output_transform: needle-transform-claude\n - invoke_template: claude-print --output-format json --model ${MODEL}\n2. install.sh — download release binary from GitHub, install to ~/.local/bin/claude-print, verify --check\n3. claude-print-ci WorkflowTemplate in jedarden/declarative-config (k8s/iad-ci/argo-workflows/)\n - verify step only (build-musl + github-release steps added in Phase 11)\n - delegates to rust-verify WorkflowTemplate\n4. --check subcommand in src/main.rs or src/check.rs\n - openpty probe: confirm openpty syscall succeeds\n - mkfifo probe: confirm mkfifo in /home/coding/.tmp succeeds\n - optional mock_claude PTY round-trip (if mock_claude binary present in PATH)\n - exits 0 on success, prints diagnostic table\n\nComplete when:\n- bash -n install.sh passes (syntactically valid)\n- Manually copying locally-built binary to ~/.local/bin/claude-print and running claude-print --check succeeds\n- NEEDLE dispatches a test bead using claude-print.yaml; AS-3 passes\n- README flags table matches claude-print --help output exactly (verified manually)\n\nReference: docs/plan/plan.md § Phase 9","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-code-glm-47-claude-print-bravo","created_at":"2026-06-10T03:54:09.318382701Z","updated_at":"2026-06-10T05:26:16.193469878Z","source_repo":".","compaction_level":0,"labels":["failure-count:1"],"dependencies":[{"issue_id":"bf-42j","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.706113490Z","created_by":"cli","thread_id":""}]} {"id":"bf-4no","title":"Phase 11: CI (~YAML only)","description":"Entry: Phase 10 complete (and Phase 9 complete for install.sh e2e test).\n\nDeliverables in jedarden/declarative-config (k8s/iad-ci/argo-workflows/claude-print-ci.yaml):\n- Update claude-print-ci WorkflowTemplate stub (from Phase 9) with full steps:\n 1. verify — delegates to rust-verify WorkflowTemplate (fmt + clippy + test + cargo audit)\n 2. build-musl — cross-compile x86_64-unknown-linux-musl release binary\n 3. build-mock-claude-musl — build test-fixtures/mock-claude/ as musl binary\n 4. github-release — upload claude-print + mock_claude binaries + last-claude-version.txt artifact to GitHub Release\n- Confirm cargo audit runs (either via rust-verify or as explicit step between verify and build-musl)\n- install.sh end-to-end download test: download release artifact from GitHub Release URL, verify install.sh exits 0 and claude-print --check passes\n\nComplete when:\n- CI run on main branch produces release binary at expected GitHub Release URL\n- last-claude-version.txt artifact present in release\n- Binary passes claude-print --check (credential-free) via install.sh\n- install.sh end-to-end download test passes (deferred from Phase 9)\n- AS-1 verified manually before pushing release tag\n\nReference: docs/plan/plan.md § Phase 11","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:27.444014247Z","updated_at":"2026-06-10T03:54:27.444014247Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-4no","depends_on_id":"bf-10t","type":"blocks","created_at":"2026-06-10T03:54:31.717358160Z","created_by":"cli","thread_id":""},{"issue_id":"bf-4no","depends_on_id":"bf-42j","type":"blocks","created_at":"2026-06-10T03:54:31.725797267Z","created_by":"cli","thread_id":""}]} {"id":"bf-5bl","title":"Starvation alert: beads invisible to worker","description":"## Starvation Alert\n\nOpen beads exist but Pluck found none — possible configuration error.\n\n**Workspace:** default\n**Total beads:** 6\n**Open:** 5\n**In-progress:** 1\n**Claimed by:** claude-glm-glm47-alpha\n\nCheck exclude_labels, workspace path, and filter configuration.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-10T03:57:06.245148475Z","updated_at":"2026-06-10T04:30:00Z","closed_at":"2026-06-10T04:30:00Z","source_repo":".","compaction_level":0,"labels":["starvation-alert"]} {"id":"bf-64k","title":"Phase 7: Transcript Reader (~180 LOC)","description":"Entry: Phase 6 complete. PO-5 acknowledged: retry loop (40×50ms) is the mitigation for Stop-before-JSONL races. Verify retry timing by running test_transcript_race with MOCK_DELAY_JSONL=100 and confirming exit 0.\n\nImplementation: src/transcript.rs\n- JSONL parse with lenient serde (unknown fields tolerated, unknown event types skipped)\n- message.id dedup + usage-fingerprint fallback dedup for events without message.id\n- Text extraction from assistant ContentBlock array (text type only)\n- 40×50ms retry loop with Stop-payload fallback to last_assistant_message after exhausted\n- Path derivation: strip leading /, replace / with -, append session_id from Stop payload\n\nComplete when:\n- All transcript unit tests pass (tests/transcript.rs)\n- test_streaming_dedup_40_retries passes\n- AS-6 (race scenario: Stop fires before JSONL flush) passes with MOCK_DELAY_JSONL=100\n\nReference: docs/plan/plan.md § Phase 7","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":1,"issue_type":"task","assignee":"claude-code-glm-47-claude-print-bravo","created_at":"2026-06-10T03:53:52.452812786Z","updated_at":"2026-06-10T05:15:23.394672440Z","closed_at":"2026-06-10T05:15:23.394672440Z","close_reason":"Completed","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-64k","depends_on_id":"bf-64s","type":"blocks","created_at":"2026-06-10T03:54:31.695602796Z","created_by":"cli","thread_id":""}]} diff --git a/README.md b/README.md index 8c19867..6837569 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,56 @@ Drop-in replacement for `claude -p` (print/headless mode) that drives the Claude Starting June 15, 2026, Anthropic separates `claude -p` (headless) into a separate Agent SDK credit pool ($100–$200/month on Max plans). Only the interactive TUI (`cc_entrypoint=cli`) continues drawing from the unlimited subscription. `claude-print` wraps the interactive TUI in a PTY so callers get `claude -p` wire-compatible output while billing against the subscription. +## Install + +```sh +sh install.sh +``` + +Or set `SKIP_MOCK_CLAUDE=1` to skip the `mock_claude` test fixture. + +## Usage + +``` +claude-print [OPTIONS] [PROMPT] +``` + +Reads prompt from positional argument, `--input-file`, or stdin (when not a TTY). + +## Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `[PROMPT]` | | | Prompt string (mutually exclusive with `--input-file` and stdin) | +| `--input-file ` | `-f` | | Read prompt from file | +| `--model ` | `-m` | `claude-sonnet-4-6` | Model to use | +| `--max-turns ` | | `30` | Maximum number of turns | +| `--output-format ` | `-o` | `text` | Output format: `text`, `json`, `stream-json` | +| `--allowedTools ` | | | Comma-separated list of allowed tools | +| `--disallowedTools ` | | | Comma-separated list of disallowed tools | +| `--dangerously-skip-permissions` | | | Skip permission prompts (dangerous) | +| `--timeout ` | | `3600` | Wall-clock timeout in seconds | +| `--claude-binary ` | | PATH lookup | Path to claude binary | +| `--no-inherit-hooks` | | | Disable user hook inheritance | +| `--verbose` | | | Write timing traces to stderr | +| `--check` | | | Run installation self-test and exit | +| `--version` | `-V` | | Print version and exit | +| `--help` | `-h` | | Print help | + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Assistant error (`is_error: true` in transcript) | +| `2` | Internal error (PTY spawn, hook setup, parse failure) | +| `124` | Timeout exceeded | +| `130` | Interrupted (SIGINT) | + +## NEEDLE integration + +Copy `claude-print.yaml` to `~/.needle/agents/` (handled automatically by `install.sh`). + ## Structure - `docs/notes/` — design decisions, constraints, integration details diff --git a/claude-print.yaml b/claude-print.yaml new file mode 100644 index 0000000..74ac8f6 --- /dev/null +++ b/claude-print.yaml @@ -0,0 +1,18 @@ +name: claude-print +description: Claude Code interactive mode — subscription billing (cc_entrypoint=cli) +agent_cli: claude-print +version_command: "claude-print --version" +input_method: + method: stdin +invoke_template: "cd {workspace} && claude-print --model {model} --max-turns 30 --output-format json --dangerously-skip-permissions --no-inherit-hooks" +timeout_secs: 3600 +provider: anthropic +# Note: --max-turns 30 and --no-inherit-hooks are hardcoded in the template above. +# --max-turns 30 takes precedence over config.toml's max_turns setting for NEEDLE-dispatched +# jobs. To change the turn limit for NEEDLE workers, edit invoke_template directly. +# NEEDLE workers run in isolation mode by default (--no-inherit-hooks is included in +# the template). To enable user hook inheritance, remove --no-inherit-hooks. +model: claude-sonnet-4-6 +output_transform: needle-transform-claude +cost: + type: use_or_lose diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..b49f4f1 --- /dev/null +++ b/install.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# install.sh — install claude-print from GitHub Releases +# Usage: sh install.sh +# Env vars: +# SKIP_MOCK_CLAUDE=1 skip mock_claude installation +set -e + +REPO="jedarden/claude-print" +INSTALL_DIR="${HOME}/.local/bin" +NEEDLE_AGENTS_DIR="${HOME}/.needle/agents" + +# Detect architecture +ARCH=$(uname -m) +case "$ARCH" in + x86_64) TARGET="x86_64-unknown-linux-musl" ;; + aarch64) TARGET="aarch64-unknown-linux-musl" ;; + *) + echo "Unsupported architecture: ${ARCH}" >&2 + exit 1 + ;; +esac + +BINARY_ASSET="claude-print-${TARGET}" +MOCK_ASSET="mock_claude-${TARGET}" + +# Verify claude is on PATH +if ! command -v claude >/dev/null 2>&1; then + echo "Error: 'claude' not found in PATH. Install Claude Code first." >&2 + exit 1 +fi + +# Create install dir +mkdir -p "${INSTALL_DIR}" + +# Download claude-print binary +BINARY_URL="https://github.com/${REPO}/releases/latest/download/${BINARY_ASSET}" +echo "Downloading ${BINARY_ASSET}..." +TMP_BIN=$(mktemp) +trap 'rm -f "${TMP_BIN}"' EXIT +curl -fsSL "${BINARY_URL}" -o "${TMP_BIN}" + +# Backup existing binary (enables one-step rollback) +if [ -f "${INSTALL_DIR}/claude-print" ]; then + echo "Backing up existing binary to ${INSTALL_DIR}/claude-print.prev" + mv "${INSTALL_DIR}/claude-print" "${INSTALL_DIR}/claude-print.prev" +fi + +install -m 755 "${TMP_BIN}" "${INSTALL_DIR}/claude-print" +echo "Installed ${INSTALL_DIR}/claude-print" + +# Install mock_claude (unless SKIP_MOCK_CLAUDE=1) +if [ "${SKIP_MOCK_CLAUDE:-0}" != "1" ]; then + MOCK_URL="https://github.com/${REPO}/releases/latest/download/${MOCK_ASSET}" + TMP_MOCK=$(mktemp) + trap 'rm -f "${TMP_BIN}" "${TMP_MOCK}"' EXIT + if curl -fsSL "${MOCK_URL}" -o "${TMP_MOCK}" 2>/dev/null; then + install -m 755 "${TMP_MOCK}" "${INSTALL_DIR}/mock_claude" + echo "Installed ${INSTALL_DIR}/mock_claude" + else + echo "Note: ${MOCK_ASSET} not found in this release — skipping mock_claude" + fi + rm -f "${TMP_MOCK}" +fi + +# Install NEEDLE agent config if NEEDLE is installed or agents dir exists +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if command -v needle >/dev/null 2>&1 || [ -d "${NEEDLE_AGENTS_DIR}" ]; then + mkdir -p "${NEEDLE_AGENTS_DIR}" + if [ -f "${SCRIPT_DIR}/claude-print.yaml" ]; then + install -m 644 "${SCRIPT_DIR}/claude-print.yaml" "${NEEDLE_AGENTS_DIR}/claude-print.yaml" + echo "Installed ${NEEDLE_AGENTS_DIR}/claude-print.yaml" + else + echo "Note: claude-print.yaml not found alongside install.sh — skipping NEEDLE config" + fi +fi + +# Verify installation +echo "" +echo "Running claude-print --check..." +if ! "${INSTALL_DIR}/claude-print" --check; then + echo "Error: claude-print --check failed" >&2 + exit 1 +fi + +echo "" +"${INSTALL_DIR}/claude-print" --version +echo "" +echo "Installation complete." diff --git a/src/check.rs b/src/check.rs new file mode 100644 index 0000000..6d740bc --- /dev/null +++ b/src/check.rs @@ -0,0 +1,243 @@ +use nix::pty::openpty; +use nix::sys::stat::Mode; +use nix::unistd::mkfifo; +use std::ffi::CString; +use std::os::unix::io::IntoRawFd; +use std::path::{Path, PathBuf}; + +struct Row { + name: &'static str, + pass: bool, + detail: String, +} + +fn find_in_path(name: &str) -> Option { + std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths).find_map(|dir| { + let p = dir.join(name); + if p.is_file() { + Some(p) + } else { + None + } + }) + }) +} + +fn probe_openpty() -> Row { + match openpty(None, None) { + Ok(pty) => { + drop(pty.master); + drop(pty.slave); + Row { + name: "openpty", + pass: true, + detail: "openpty() syscall succeeded".into(), + } + } + Err(e) => Row { + name: "openpty", + pass: false, + detail: format!("openpty() failed: {e}"), + }, + } +} + +fn probe_mkfifo() -> Row { + let tmp = std::env::temp_dir(); + let path = tmp.join(format!("claude-print-check-{}.fifo", std::process::id())); + match mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR) { + Ok(_) => { + let _ = std::fs::remove_file(&path); + Row { + name: "mkfifo", + pass: true, + detail: format!("mkfifo succeeded (dir: {})", tmp.display()), + } + } + Err(e) => Row { + name: "mkfifo", + pass: false, + detail: format!("mkfifo failed: {e}"), + }, + } +} + +fn wait_with_timeout(pid: nix::unistd::Pid, timeout_secs: u64) -> Option { + use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; + let start = std::time::Instant::now(); + loop { + match waitpid(pid, Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::Exited(_, code)) => return Some(code), + Ok(WaitStatus::Signaled(_, _, _)) => return None, + _ => {} + } + if start.elapsed().as_secs() >= timeout_secs { + let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGKILL); + let _ = waitpid(pid, None); + return None; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } +} + +fn probe_mock_claude_pty(mock_path: &Path) -> Row { + use nix::unistd::{fork, ForkResult}; + + let tmp = std::env::temp_dir(); + let fifo_path = tmp.join(format!( + "claude-print-check-mock-{}.fifo", + std::process::id() + )); + + if mkfifo(&fifo_path, Mode::S_IRUSR | Mode::S_IWUSR).is_err() { + return Row { + name: "mock_claude PTY", + pass: false, + detail: "mkfifo for round-trip failed".into(), + }; + } + + // Open FIFO read end O_RDONLY|O_NONBLOCK — on Linux this succeeds immediately + // even without a writer, allowing mock_claude's O_WRONLY open to succeed. + let fifo_cstr = + CString::new(fifo_path.to_string_lossy().as_bytes()).expect("fifo path is valid CStr"); + let fifo_rfd = + unsafe { libc::open(fifo_cstr.as_ptr(), libc::O_RDONLY | libc::O_NONBLOCK) }; + if fifo_rfd < 0 { + let _ = std::fs::remove_file(&fifo_path); + return Row { + name: "mock_claude PTY", + pass: false, + detail: "open FIFO O_RDONLY|O_NONBLOCK failed".into(), + }; + } + + let nix::pty::OpenptyResult { master, slave } = match openpty(None, None) { + Ok(p) => p, + Err(e) => { + unsafe { libc::close(fifo_rfd) }; + let _ = std::fs::remove_file(&fifo_path); + return Row { + name: "mock_claude PTY", + pass: false, + detail: format!("openpty for round-trip failed: {e}"), + }; + } + }; + + let mock_cstr = + CString::new(mock_path.to_string_lossy().as_bytes()).expect("mock path is valid CStr"); + let fifo_arg = fifo_cstr.clone(); + + let child = match unsafe { fork() } { + Ok(ForkResult::Parent { child }) => { + drop(slave); + child + } + Ok(ForkResult::Child) => { + drop(master); + unsafe { libc::close(fifo_rfd) }; + let slave_fd = slave.into_raw_fd(); + if unsafe { libc::login_tty(slave_fd) } != 0 { + unsafe { libc::_exit(127) } + } + let _ = nix::unistd::execvp( + mock_cstr.as_c_str(), + &[mock_cstr.as_c_str(), fifo_arg.as_c_str()], + ); + unsafe { libc::_exit(127) } + } + Err(e) => { + drop(master); + drop(slave); + unsafe { libc::close(fifo_rfd) }; + let _ = std::fs::remove_file(&fifo_path); + return Row { + name: "mock_claude PTY", + pass: false, + detail: format!("fork failed: {e}"), + }; + } + }; + + let exit_code = wait_with_timeout(child, 5); + + drop(master); + unsafe { libc::close(fifo_rfd) }; + let _ = std::fs::remove_file(&fifo_path); + + match exit_code { + Some(0) => Row { + name: "mock_claude PTY", + pass: true, + detail: format!( + "PTY round-trip OK — isatty=true in child ({})", + mock_path.display() + ), + }, + Some(code) => Row { + name: "mock_claude PTY", + pass: false, + detail: format!("mock_claude exited {code} (expected 0; isatty=false)"), + }, + None => Row { + name: "mock_claude PTY", + pass: false, + detail: "mock_claude timed out or was killed".into(), + }, + } +} + +pub fn run() -> i32 { + let mut rows: Vec = Vec::new(); + let mut all_pass = true; + + let r = probe_openpty(); + if !r.pass { + all_pass = false; + } + rows.push(r); + + let r = probe_mkfifo(); + if !r.pass { + all_pass = false; + } + rows.push(r); + + if let Some(mock_path) = find_in_path("mock_claude") { + let r = probe_mock_claude_pty(&mock_path); + if !r.pass { + all_pass = false; + } + rows.push(r); + } + + let name_w = 20usize; + let res_w = 6usize; + println!( + "{:"); + // Positional arg 1 is the FIFO path (legacy mode used by test_pty_spawns_tty). + let fifo_path = std::env::args().nth(1); - let omit_transcript_path = std::env::var("MOCK_OMIT_TRANSCRIPT_PATH") - .map(|v| v == "1") - .unwrap_or(false); + // ── Env var controls ────────────────────────────────────────────────────── + let mock_silent = env_flag("MOCK_SILENT"); + let mock_exit_before_stop = env_flag("MOCK_EXIT_BEFORE_STOP"); + let mock_delay_stop_ms: u64 = env_u64("MOCK_DELAY_STOP", 0); + let mock_trust_dialog = env_flag("MOCK_TRUST_DIALOG"); + let mock_trust_wording = std::env::var("MOCK_TRUST_WORDING").unwrap_or_default(); + let mock_unknown_probe = env_flag("MOCK_UNKNOWN_PROBE"); + let mock_response = std::env::var("MOCK_RESPONSE") + .unwrap_or_else(|_| "Hello from mock_claude".to_string()); + let omit_transcript_path = env_flag("MOCK_OMIT_TRANSCRIPT_PATH"); + let omit_last_message = env_flag("MOCK_OMIT_LAST_MESSAGE"); + + // MOCK_SILENT: block forever without firing Stop (tests timeout path) + if mock_silent { + loop { + thread::sleep(Duration::from_secs(3600)); + } + } + + // Optionally emit an unknown ESC sequence (tests unknown-probe resilience) + if mock_unknown_probe { + print!("\x1b[999t"); + std::io::stdout().flush().ok(); + } + + // Optionally emit trust dialog text + if mock_trust_dialog { + if mock_trust_wording == "alternate" { + // Uses "continue" + "folder" as trust keywords + print!("Do you want to continue and grant permission to this folder?\r\n"); + } else { + // Standard wording uses "trust" + "Allow" + print!("Do you trust and Allow access to this folder?\r\n"); + } + std::io::stdout().flush().ok(); + } + + // MOCK_EXIT_BEFORE_STOP: exit without writing to the FIFO (tests child-exit-before-Stop) + if mock_exit_before_stop { + std::process::exit(1); + } + + // Delay Stop if requested + if mock_delay_stop_ms > 0 { + thread::sleep(Duration::from_millis(mock_delay_stop_ms)); + } + + let Some(fifo_path) = fifo_path else { + // No FIFO path provided — exit cleanly (used when invoked without args) + std::process::exit(0); + }; let session_id = "mock-session-abc123"; let cwd = std::env::current_dir() @@ -15,15 +64,22 @@ fn main() { .unwrap_or_else(|_| "/tmp".to_string()); let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); - // Build Stop hook JSON payload manually (no serde_json dep in mock-claude). - // Paths on Linux cannot contain backslashes or control chars, so no escaping needed. + let last_msg_part = if omit_last_message { + String::new() + } else { + format!( + ",\"last_assistant_message\":\"{}\"", + mock_response.replace('\\', "\\\\").replace('"', "\\\"") + ) + }; + let payload = if omit_transcript_path { format!( - "{{\"hook_event_name\":\"Stop\",\"session_id\":\"{session_id}\",\"cwd\":\"{cwd}\",\"last_assistant_message\":\"Hello from mock_claude\"}}\n" + "{{\"hook_event_name\":\"Stop\",\"session_id\":\"{session_id}\",\"cwd\":\"{cwd}\"{last_msg_part}}}\n" ) } else { format!( - "{{\"hook_event_name\":\"Stop\",\"session_id\":\"{session_id}\",\"transcript_path\":\"{home}/.claude/projects/mock-cwd/{session_id}.jsonl\",\"cwd\":\"{cwd}\",\"last_assistant_message\":\"Hello from mock_claude\"}}\n" + "{{\"hook_event_name\":\"Stop\",\"session_id\":\"{session_id}\",\"transcript_path\":\"{home}/.claude/projects/mock-cwd/{session_id}.jsonl\",\"cwd\":\"{cwd}\"{last_msg_part}}}\n" ) }; @@ -36,3 +92,14 @@ fn main() { let has_tty = unsafe { libc::isatty(libc::STDIN_FILENO) } == 1; std::process::exit(if has_tty { 0 } else { 1 }); } + +fn env_flag(key: &str) -> bool { + std::env::var(key).map(|v| v == "1").unwrap_or(false) +} + +fn env_u64(key: &str, default: u64) -> u64 { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..ed76c42 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,174 @@ +/// CLI tests (Phase 10). +/// +/// Verifies that the CLI struct parses correctly and that the `version_string` +/// helper produces the expected output. +use claude_print::cli::{version_string, Cli, OutputFormat}; +use clap::Parser; + +// ── OutputFormat Display ────────────────────────────────────────────────────── + +#[test] +fn output_format_display_text() { + assert_eq!(format!("{}", OutputFormat::Text), "text"); +} + +#[test] +fn output_format_display_json() { + assert_eq!(format!("{}", OutputFormat::Json), "json"); +} + +#[test] +fn output_format_display_stream_json() { + assert_eq!(format!("{}", OutputFormat::StreamJson), "stream-json"); +} + +// ── version_string format ───────────────────────────────────────────────────── + +#[test] +fn version_string_contains_claude_print_prefix() { + let s = version_string(Some("2.1.168")); + assert!( + s.starts_with("claude-print "), + "version string must start with 'claude-print '; got: {s:?}" + ); +} + +#[test] +fn version_string_contains_wrapping_claude() { + let s = version_string(Some("2.1.168")); + assert!( + s.contains("wrapping claude 2.1.168"), + "version string must contain 'wrapping claude '; got: {s:?}" + ); +} + +#[test] +fn version_string_not_found() { + let s = version_string(None); + assert!( + s.contains("not found"), + "version string with None must say 'not found'; got: {s:?}" + ); +} + +#[test] +fn version_string_format_is_parseable() { + let s = version_string(Some("2.0.0")); + // Must match: "claude-print X.Y.Z (wrapping claude A.B.C)" + assert!(s.contains('('), "must have opening paren"); + assert!(s.contains(')'), "must have closing paren"); + let inside: &str = s.split('(').nth(1).and_then(|s| s.split(')').next()).unwrap_or(""); + assert!( + inside.starts_with("wrapping claude"), + "content in parens must start with 'wrapping claude'; got: {inside:?}" + ); +} + +// ── CLI argument parsing ────────────────────────────────────────────────────── + +#[test] +fn cli_positional_prompt_parsed() { + let cli = Cli::try_parse_from(["claude-print", "hello world"]).unwrap(); + assert_eq!(cli.prompt.as_deref(), Some("hello world")); +} + +#[test] +fn cli_no_prompt_gives_none() { + let cli = Cli::try_parse_from(["claude-print"]).unwrap(); + assert!(cli.prompt.is_none(), "no positional arg should give None prompt"); +} + +#[test] +fn cli_output_format_default_is_text() { + let cli = Cli::try_parse_from(["claude-print"]).unwrap(); + assert!(matches!(cli.output_format, OutputFormat::Text)); +} + +#[test] +fn cli_output_format_json() { + let cli = + Cli::try_parse_from(["claude-print", "--output-format", "json"]).unwrap(); + assert!(matches!(cli.output_format, OutputFormat::Json)); +} + +#[test] +fn cli_output_format_stream_json() { + let cli = + Cli::try_parse_from(["claude-print", "--output-format", "stream-json"]).unwrap(); + assert!(matches!(cli.output_format, OutputFormat::StreamJson)); +} + +#[test] +fn cli_output_format_invalid_returns_error() { + let result = Cli::try_parse_from(["claude-print", "--output-format", "xml"]); + assert!(result.is_err(), "invalid output format must produce a parse error"); +} + +#[test] +fn cli_no_inherit_hooks_flag() { + let cli = Cli::try_parse_from(["claude-print", "--no-inherit-hooks"]).unwrap(); + assert!(cli.no_inherit_hooks, "--no-inherit-hooks must set no_inherit_hooks=true"); +} + +#[test] +fn cli_no_inherit_hooks_defaults_false() { + let cli = Cli::try_parse_from(["claude-print"]).unwrap(); + assert!(!cli.no_inherit_hooks, "no_inherit_hooks must default to false"); +} + +#[test] +fn cli_verbose_flag() { + let cli = Cli::try_parse_from(["claude-print", "--verbose"]).unwrap(); + assert!(cli.verbose); +} + +#[test] +fn cli_dangerously_skip_permissions_flag() { + let cli = + Cli::try_parse_from(["claude-print", "--dangerously-skip-permissions"]).unwrap(); + assert!(cli.dangerously_skip_permissions); +} + +#[test] +fn cli_max_turns_default_is_30() { + let cli = Cli::try_parse_from(["claude-print"]).unwrap(); + assert_eq!(cli.max_turns, 30, "max_turns must default to 30"); +} + +#[test] +fn cli_max_turns_custom() { + let cli = Cli::try_parse_from(["claude-print", "--max-turns", "5"]).unwrap(); + assert_eq!(cli.max_turns, 5); +} + +#[test] +fn cli_timeout_default_is_3600() { + let cli = Cli::try_parse_from(["claude-print"]).unwrap(); + assert_eq!(cli.timeout, 3600, "timeout must default to 3600"); +} + +#[test] +fn cli_input_file_flag() { + let cli = + Cli::try_parse_from(["claude-print", "--input-file", "/tmp/prompt.txt"]).unwrap(); + assert_eq!( + cli.input_file.as_deref(), + Some(std::path::Path::new("/tmp/prompt.txt")) + ); +} + +#[test] +fn cli_claude_binary_flag() { + let cli = + Cli::try_parse_from(["claude-print", "--claude-binary", "/usr/bin/claude"]).unwrap(); + assert_eq!( + cli.claude_binary.as_deref(), + Some(std::path::Path::new("/usr/bin/claude")) + ); +} + +#[test] +fn cli_model_flag() { + let cli = Cli::try_parse_from(["claude-print", "--model", "claude-opus-4-8"]).unwrap(); + assert_eq!(cli.model.as_deref(), Some("claude-opus-4-8")); +} diff --git a/tests/fixtures/transcript_v2.1.168.jsonl b/tests/fixtures/transcript_v2.1.168.jsonl new file mode 100644 index 0000000..0018830 --- /dev/null +++ b/tests/fixtures/transcript_v2.1.168.jsonl @@ -0,0 +1,5 @@ +{"type":"assistant","message":{"id":"msg-001","content":[{"type":"text","text":"This is a test response"}],"usage":{"input_tokens":6178,"output_tokens":295,"cache_creation_input_tokens":825,"cache_read_input_tokens":26442,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":825},"inference_geo":"","speed":"standard"}}} +{"type":"assistant","message":{"id":"msg-002","content":[{"type":"text","text":"chunk1 "}],"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":5000,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","speed":"standard"}}} +{"type":"assistant","message":{"id":"msg-002","content":[{"type":"text","text":"chunk2 "}],"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":5000,"service_tier":"standard","speed":"standard"}}} +{"type":"assistant","message":{"id":"msg-002","content":[{"type":"text","text":"chunk3"}],"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":5000,"service_tier":"standard","speed":"standard"}}} +{"type":"result","is_error":false,"session_id":"test-session-v2168","duration_ms":1234} diff --git a/tests/hooks.rs b/tests/hooks.rs new file mode 100644 index 0000000..b4a31a0 --- /dev/null +++ b/tests/hooks.rs @@ -0,0 +1,180 @@ +/// Hook inheritance tests (Phase 10). +/// +/// Verifies the hook installer creates the correct artifacts and that the temp +/// dir lifecycle works correctly. These tests correspond to the "Hook +/// Inheritance Tests" section of the plan. +use claude_print::hook::HookInstaller; +use std::os::unix::fs::PermissionsExt; + +// ── settings.json structure ─────────────────────────────────────────────────── + +#[test] +fn settings_json_double_nested_hooks_structure() { + let installer = HookInstaller::new().unwrap(); + let content = std::fs::read_to_string(&installer.settings_path).unwrap(); + let val: serde_json::Value = serde_json::from_str(&content).unwrap(); + // Schema: { "hooks": { "Stop": [{ "hooks": [{"type":"command", ...}] }] } } + let stop = &val["hooks"]["Stop"]; + assert!(stop.is_array(), "Stop must be an array"); + let outer = &stop[0]; + assert!(outer.is_object(), "first Stop entry must be an object"); + let inner = &outer["hooks"]; + assert!(inner.is_array(), "hooks inside Stop entry must be an array"); + assert!(!inner.as_array().unwrap().is_empty(), "inner hooks must be non-empty"); +} + +#[test] +fn settings_json_hook_type_is_command() { + let installer = HookInstaller::new().unwrap(); + let content = std::fs::read_to_string(&installer.settings_path).unwrap(); + let val: serde_json::Value = serde_json::from_str(&content).unwrap(); + let hook = &val["hooks"]["Stop"][0]["hooks"][0]; + assert_eq!(hook["type"], "command", "hook type must be 'command'"); +} + +#[test] +fn settings_json_stop_hook_timeout_is_10() { + let installer = HookInstaller::new().unwrap(); + let content = std::fs::read_to_string(&installer.settings_path).unwrap(); + let val: serde_json::Value = serde_json::from_str(&content).unwrap(); + let timeout = &val["hooks"]["Stop"][0]["hooks"][0]["timeout"]; + assert_eq!(timeout, 10, "relay hook timeout must be 10 seconds"); +} + +#[test] +fn settings_json_command_references_hook_sh() { + let installer = HookInstaller::new().unwrap(); + let content = std::fs::read_to_string(&installer.settings_path).unwrap(); + let val: serde_json::Value = serde_json::from_str(&content).unwrap(); + let cmd = val["hooks"]["Stop"][0]["hooks"][0]["command"] + .as_str() + .unwrap_or(""); + assert!( + cmd.contains("hook.sh"), + "command must reference hook.sh, got: {cmd:?}" + ); + // Must reference the hook.sh within the temp dir + assert!( + cmd.starts_with(installer.dir_path().to_str().unwrap()), + "command must be within the temp dir" + ); +} + +// ── hook.sh format ──────────────────────────────────────────────────────────── + +#[test] +fn hook_sh_shebang_is_sh() { + let installer = HookInstaller::new().unwrap(); + let content = std::fs::read_to_string(&installer.hook_path).unwrap(); + assert!( + content.starts_with("#!/bin/sh"), + "hook.sh must start with #!/bin/sh, got: {content:?}" + ); +} + +#[test] +fn hook_sh_uses_cat_to_write_stdin_to_fifo() { + let installer = HookInstaller::new().unwrap(); + let content = std::fs::read_to_string(&installer.hook_path).unwrap(); + assert!( + content.contains("cat >"), + "hook.sh must use cat > to pipe stdin to FIFO" + ); + assert!( + content.contains("stop.fifo"), + "hook.sh must reference stop.fifo" + ); +} + +#[test] +fn hook_sh_has_fire_and_forget_pattern() { + let installer = HookInstaller::new().unwrap(); + let content = std::fs::read_to_string(&installer.hook_path).unwrap(); + // "|| true" ensures hook.sh exits 0 even if the FIFO write fails (T-4 mitigation) + assert!( + content.contains("|| true"), + "hook.sh must use '|| true' for fire-and-forget; got: {content:?}" + ); +} + +#[test] +fn hook_sh_fifo_path_is_single_quoted() { + let installer = HookInstaller::new().unwrap(); + let content = std::fs::read_to_string(&installer.hook_path).unwrap(); + // T-4: fifo path embedded with single quotes prevents shell expansion. + // The cat command should be: cat > '' + assert!( + content.contains("cat > '"), + "hook.sh must use single-quoted FIFO path (T-4 mitigation); got: {content:?}" + ); +} + +// ── Temp dir lifecycle ──────────────────────────────────────────────────────── + +#[test] +fn hook_sh_and_fifo_paths_are_within_temp_dir() { + let installer = HookInstaller::new().unwrap(); + let temp_dir = installer.dir_path().to_path_buf(); + assert!( + installer.hook_path.starts_with(&temp_dir), + "hook.sh must be inside the temp dir" + ); + assert!( + installer.fifo_path.starts_with(&temp_dir), + "stop.fifo must be inside the temp dir" + ); + assert!( + installer.settings_path.starts_with(&temp_dir), + "settings.json must be inside the temp dir" + ); +} + +#[test] +fn hook_sh_is_executable() { + let installer = HookInstaller::new().unwrap(); + let meta = std::fs::metadata(&installer.hook_path).unwrap(); + let mode = meta.permissions().mode(); + // Owner execute bit (0o100) must be set + assert!( + mode & 0o100 != 0, + "hook.sh must be executable by owner; mode = {:#o}", + mode + ); +} + +#[test] +fn temp_dir_prefix_contains_claude_print() { + let installer = HookInstaller::new().unwrap(); + let dir_name = installer + .dir_path() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + assert!( + dir_name.contains("claude-print"), + "temp dir name must contain 'claude-print'; got: {dir_name:?}" + ); +} + +// ── Settings JSON is valid ──────────────────────────────────────────────────── + +#[test] +fn settings_json_is_valid_json() { + let installer = HookInstaller::new().unwrap(); + let content = std::fs::read_to_string(&installer.settings_path).unwrap(); + let result: Result = serde_json::from_str(&content); + assert!(result.is_ok(), "settings.json must be valid JSON"); +} + +// ── No-inherit-hooks mode: settings path independent of user settings ───────── + +#[test] +fn hook_installer_temp_dir_is_independent_of_home() { + let installer = HookInstaller::new().unwrap(); + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let temp_path = installer.dir_path().to_str().unwrap_or(""); + assert!( + !temp_path.starts_with(&home) || temp_path.contains("tmp"), + "temp dir should not be inside HOME/.claude (would break isolation)" + ); +} diff --git a/tests/version_compat.rs b/tests/version_compat.rs new file mode 100644 index 0000000..d8289b8 --- /dev/null +++ b/tests/version_compat.rs @@ -0,0 +1,211 @@ +/// Version-resilience test suite (Phase 10). +/// +/// Verifies that `claude-print` survives Claude Code schema changes without +/// rebuilding. All tests are credential-free and run in CI on every push. +use claude_print::poller::parse_stop_payload; +use claude_print::startup::StartupSeq; +use claude_print::transcript::parse_transcript; +use std::io::Write as IoWrite; +use std::path::Path; +use tempfile::TempDir; + +// ── Stop payload with 50 unknown extra fields ───────────────────────────────── + +#[test] +fn stop_payload_50_unknown_fields_parsed_without_error() { + let mut json = + String::from(r#"{"hook_event_name":"Stop","session_id":"sid1","cwd":"/tmp/x""#); + for i in 0..50 { + json.push_str(&format!(r#","future_field_{i}":"value_{i}""#)); + } + json.push('}'); + let p = parse_stop_payload(json.as_bytes()).expect("must parse with 50 unknown fields"); + assert_eq!(p.session_id.as_deref(), Some("sid1"), "session_id must survive unknown fields"); + assert_eq!(p.cwd.as_deref(), Some("/tmp/x"), "cwd must survive unknown fields"); +} + +// ── Usage object with 20 new numeric fields ─────────────────────────────────── + +#[test] +fn usage_20_new_numeric_fields_ignored_known_fields_correct() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("t.jsonl"); + + let mut usage = serde_json::json!({ + "input_tokens": 100, + "output_tokens": 50, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 20, + }); + let obj = usage.as_object_mut().unwrap(); + for i in 0..20u64 { + obj.insert( + format!("future_metric_{i}"), + serde_json::Value::Number(serde_json::Number::from(i)), + ); + } + let event = serde_json::json!({ + "type": "assistant", + "message": { + "id": "msg-new-usage", + "content": [{"type": "text", "text": "ok"}], + "usage": usage, + } + }); + let mut file = std::fs::File::create(&path).unwrap(); + writeln!(file, "{}", event).unwrap(); + drop(file); + + let r = parse_transcript(&path).unwrap(); + assert_eq!(r.usage.input_tokens, 100, "input_tokens wrong"); + assert_eq!(r.usage.output_tokens, 50, "output_tokens wrong"); + assert_eq!(r.usage.cache_creation_input_tokens, 10, "cache_create wrong"); + assert_eq!(r.usage.cache_read_input_tokens, 20, "cache_read wrong"); + assert_eq!(r.num_turns, 1); +} + +// ── Content block with new type and required fields → Unknown via #[serde(other)] + +#[test] +fn content_block_new_type_with_required_field_treated_as_unknown() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("t.jsonl"); + let event = serde_json::json!({ + "type": "assistant", + "message": { + "id": "msg-future-block", + "content": [ + { + "type": "future_rich_media", + "required_in_new_version": "some-value", + "extra_metadata": {"version": 3} + }, + {"type": "text", "text": "extracted text"} + ], + "usage": { + "input_tokens": 5, "output_tokens": 3, + "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0 + } + } + }); + let mut file = std::fs::File::create(&path).unwrap(); + writeln!(file, "{}", event).unwrap(); + drop(file); + + let r = parse_transcript(&path).unwrap(); + assert_eq!(r.text, "extracted text", "text after unknown block must be extracted"); + assert_eq!(r.num_turns, 1); +} + +// ── JSONL with events in a new order ───────────────────────────────────────── + +#[test] +fn jsonl_events_in_new_order_parse_succeeds() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("t.jsonl"); + // A hypothetical "summary" event appears before user/assistant in a future version. + let lines = [ + r#"{"type":"summary","content":"A new summary event type","model":"claude-5"}"#, + r#"{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}"#, + r#"{"type":"assistant","message":{"id":"msg-ord","content":[{"type":"text","text":"world"}],"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#, + r#"{"type":"result","is_error":false,"session_id":"ord-session"}"#, + ]; + let mut file = std::fs::File::create(&path).unwrap(); + for line in &lines { + writeln!(file, "{}", line).unwrap(); + } + drop(file); + + let r = parse_transcript(&path).unwrap(); + assert_eq!(r.text, "world", "text must be extracted despite new event order"); + assert_eq!(r.num_turns, 1); + assert_eq!(r.session_id.as_deref(), Some("ord-session")); +} + +// ── Startup heuristic stability: 20 trust dialog phrasings must all trigger ─── + +#[test] +fn startup_20_trust_dialog_phrasings_all_trigger() { + let phrasings: &[&str] = &[ + "Do you trust and Allow access to this folder?", + "Grant permission to proceed with this folder", + "Please trust and continue to allow", + "Allow and continue access to this folder", + "Do you want to proceed and trust this folder?", + "Permission required: trust to continue", + "Trust this folder and proceed with Allow", + "continue and allow this folder permission", + "Grant trust, proceed, and allow folder access", + "Please trust, allow, and continue in this folder", + "Permission to proceed: trust and allow folder", + "Trust dialog: allow and continue with folder", + "You must trust and continue to allow folder access", + "Do you Allow and trust this folder to proceed?", + "Before continuing, trust and allow this folder", + "Allow permission to proceed and trust folder", + "This action requires trust and proceed to continue", + "To allow folder access, trust and proceed", + "Grant access: trust, allow, and proceed with folder", + "Confirm permission: trust to allow and continue", + ]; + for &phrasing in phrasings { + assert!( + StartupSeq::scan_line(phrasing.as_bytes()), + "expected trust dialog trigger for: {phrasing:?}" + ); + } +} + +// ── Startup heuristic stability: 10 non-dialog lines must not trigger ───────── + +#[test] +fn startup_10_non_dialog_lines_do_not_trigger() { + let non_dialogs: &[&str] = &[ + "Initializing Claude Code v2.1.168...", + "Loading configuration...", + "Reading context from files", + "Connecting to API endpoint", + "claude-print started", + "Processing your request", + "", + " ", + "\x1b[31mError\x1b[0m: Something went wrong", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + ]; + for &line in non_dialogs { + assert!( + !StartupSeq::scan_line(line.as_bytes()), + "expected NO trigger for non-dialog line: {line:?}" + ); + } +} + +// ── Token count regression: fixture transcript_v2.1.168.jsonl ───────────────── + +#[test] +fn token_regression_fixture_v2_1_168() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/transcript_v2.1.168.jsonl"); + let r = parse_transcript(&path).expect("parse fixture failed"); + // Turn 1: msg-001 — in=6178, out=295, cache_create=825, cache_read=26442 + // Turn 2: msg-002 × 3 streaming chunks — in=100, out=50, cache_create=0, cache_read=5000 + assert_eq!(r.num_turns, 2, "fixture has 2 unique assistant turns"); + assert_eq!(r.usage.input_tokens, 6278, "input_tokens mismatch (6178 + 100)"); + assert_eq!(r.usage.output_tokens, 345, "output_tokens mismatch (295 + 50)"); + assert_eq!(r.usage.cache_creation_input_tokens, 825, "cache_creation mismatch (825 + 0)"); + assert_eq!(r.usage.cache_read_input_tokens, 31442, "cache_read mismatch (26442 + 5000)"); + // Last turn's text is the concatenation of the 3 streaming chunks + assert_eq!(r.text, "chunk1 chunk2 chunk3", "last turn text mismatch"); +} + +// ── Fixture also contains unknown usage fields → ignored ────────────────────── + +#[test] +fn fixture_unknown_usage_fields_ignored() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/transcript_v2.1.168.jsonl"); + // The fixture contains `server_tool_use`, `service_tier`, `cache_creation`, + // `inference_geo`, `speed` in the usage object — all should be silently ignored. + let r = parse_transcript(&path).expect("parse fixture must succeed"); + assert!(r.num_turns > 0, "must parse at least one turn despite unknown usage fields"); +}