Add Phase 9: NEEDLE integration — install.sh, claude-print.yaml, --check subcommand

- claude-print.yaml: NEEDLE agent config with stdin input_method, needle-transform-claude
  output_transform, and invoke_template for subscription-billed claude-print runs
- install.sh: download release binary from GitHub, backup existing, install mock_claude,
  install NEEDLE config if present, run --check to verify, print --version
- src/check.rs: --check doctor subcommand with openpty probe, mkfifo probe, and optional
  mock_claude PTY round-trip (skipped if mock_claude not in PATH)
- src/main.rs + src/lib.rs: wire up check::run() for --check flag
- README.md: add Install, Usage, Flags table (matches --help exactly), Exit codes,
  and NEEDLE integration sections
- test-fixtures/mock-claude: extend with all MOCK_* env var controls needed for
  integration tests (MOCK_SILENT, MOCK_EXIT_BEFORE_STOP, MOCK_TRUST_DIALOG, etc.)
- tests/cli.rs, tests/hooks.rs, tests/version_compat.rs: Phase 10 unit test stubs

claude-print --check passes: openpty PASS, mkfifo PASS, mock_claude PTY PASS
bash -n install.sh: syntax OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-06-10 01:36:20 -04:00
parent 08420de402
commit 50b213285a
12 changed files with 1051 additions and 14 deletions

View file

@ -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":""}]}

View file

@ -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 <FILE>` | `-f` | | Read prompt from file |
| `--model <MODEL>` | `-m` | `claude-sonnet-4-6` | Model to use |
| `--max-turns <N>` | | `30` | Maximum number of turns |
| `--output-format <FORMAT>` | `-o` | `text` | Output format: `text`, `json`, `stream-json` |
| `--allowedTools <LIST>` | | | Comma-separated list of allowed tools |
| `--disallowedTools <LIST>` | | | Comma-separated list of disallowed tools |
| `--dangerously-skip-permissions` | | | Skip permission prompts (dangerous) |
| `--timeout <SECS>` | | `3600` | Wall-clock timeout in seconds |
| `--claude-binary <PATH>` | | 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

18
claude-print.yaml Normal file
View file

@ -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

88
install.sh Normal file
View file

@ -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."

243
src/check.rs Normal file
View file

@ -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<PathBuf> {
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<i32> {
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<Row> = 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!(
"{:<name_w$} {:<res_w$} {}",
"CHECK", "RESULT", "DETAIL",
name_w = name_w,
res_w = res_w
);
println!("{}", "-".repeat(72));
for row in &rows {
println!(
"{:<name_w$} {:<res_w$} {}",
row.name,
if row.pass { "PASS" } else { "FAIL" },
row.detail,
name_w = name_w,
res_w = res_w
);
}
println!();
if all_pass {
println!("All checks passed.");
0
} else {
eprintln!("One or more checks FAILED.");
2
}
}

View file

@ -1,3 +1,4 @@
pub mod check;
pub mod cli;
pub mod config;
pub mod emitter;

View file

@ -29,8 +29,8 @@ fn main() {
}
if cli.check {
eprintln!("check: not yet implemented");
process::exit(2);
let code = claude_print::check::run();
process::exit(code);
}
eprintln!("claude-print: not yet implemented");

View file

@ -1,13 +1,62 @@
use std::io::Write;
use std::thread;
use std::time::Duration;
fn main() {
let fifo_path = std::env::args()
.nth(1)
.expect("usage: mock-claude <fifo-path>");
// 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)
}

174
tests/cli.rs Normal file
View file

@ -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 <version>'; 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"));
}

View file

@ -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}

180
tests/hooks.rs Normal file
View file

@ -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 > '<path>'
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::Value, _> = 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)"
);
}

211
tests/version_compat.rs Normal file
View file

@ -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");
}