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:
parent
08420de402
commit
50b213285a
12 changed files with 1051 additions and 14 deletions
|
|
@ -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":""}]}
|
||||
|
|
|
|||
50
README.md
50
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 <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
18
claude-print.yaml
Normal 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
88
install.sh
Normal 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
243
src/check.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod check;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod emitter;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
174
tests/cli.rs
Normal 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"));
|
||||
}
|
||||
5
tests/fixtures/transcript_v2.1.168.jsonl
vendored
Normal file
5
tests/fixtures/transcript_v2.1.168.jsonl
vendored
Normal 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
180
tests/hooks.rs
Normal 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
211
tests/version_compat.rs
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue