command-stream 0.17.1

Modern shell command execution library with streaming, async iteration, and event support
Documentation
# command-stream for Rust

[![crates.io](https://img.shields.io/crates/v/command-stream.svg)](https://crates.io/crates/command-stream)
[![Rust CI](https://github.com/link-foundation/command-stream/actions/workflows/rust.yml/badge.svg)](https://github.com/link-foundation/command-stream/actions/workflows/rust.yml)
[![License](https://img.shields.io/badge/license-Unlicense-blue.svg)](../LICENSE)

Rust implementation of command-stream: a shell command execution library with
streaming, events, shell parsing, virtual commands, and built-in command support.

## Installation

```bash
cargo add command-stream
```

## Library Usage

```rust
use command_stream::CommandResult;
use command_stream::commands::echo::EchoCommand;
use command_stream::commands::VirtualCommand;

#[tokio::main]
async fn main() {
    let command = EchoCommand;
    let result: CommandResult = command
        .execute(&["hello".to_string(), "from".to_string(), "rust".to_string()], None)
        .await
        .expect("echo should run");

    assert_eq!(result.stdout.trim(), "hello from rust");

    // `exit_code()` is an alias for the `code` field, mirroring the
    // JavaScript `exitCode` alias.
    assert_eq!(result.exit_code(), result.code);
}
```

## Streaming

`StreamingRunner` streams output as it arrives and mirrors the JavaScript
`stream()` async iterator (issue #155):

```rust
use command_stream::{OutputChunk, StreamingRunner};

#[tokio::main]
async fn main() {
    // `kill_signal` configures the stop signal (default SIGTERM), just like the
    // JavaScript `killSignal` option.
    let runner = StreamingRunner::new("sh -c 'while true; do echo tick; sleep 0.1; done'")
        .kill_signal("SIGINT");
    let mut stream = runner.stream();

    let mut count = 0;
    while let Some(chunk) = stream.next().await {
        match chunk {
            OutputChunk::Stdout(data) => {
                print!("{}", String::from_utf8_lossy(&data));
                count += 1;
                if count >= 3 {
                    stream.kill(); // stop from inside the loop (uses SIGINT)
                }
            }
            OutputChunk::Stderr(_) => {}
            // A terminating exit chunk is always delivered (128 + signal => 130).
            OutputChunk::Exit(code) => println!("exit: {code}"),
        }
    }
}
```

Parity guarantees with the JavaScript implementation:

- The stream yields a final `OutputChunk::Exit(code)` when the process exits.
- It never hangs when the process has exited but a grandchild keeps the stdio
  pipes open — readers are drained for `exit_pump_grace_ms` (default 100ms) and
  then aborted.
- The process can be stopped from inside the loop with `stream.kill()` (configured
  signal) or `stream.kill_with(signal)` (explicit override); dropping the stream
  (e.g. `break`) stops the process too.

`StreamingRunner::new(command)` interprets a completed command string with the
platform shell. When argument boundaries must be preserved exactly, pass the
executable and arguments separately with `from_argv`:

```rust,no_run
use command_stream::StreamingRunner;

#[tokio::main]
async fn main() -> command_stream::Result<()> {
    let runner = StreamingRunner::from_argv(
        "my-program",
        ["argument with spaces", "literal&metacharacters"],
    );
    let result = runner.collect().await?;

    assert!(result.is_success());
    Ok(())
}
```

The exact-argv form bypasses `/bin/sh -c` and `cmd.exe /c`, so it does not
require shell-specific quoting. It also accepts OS-native executable and
argument values such as `PathBuf` and `OsString`.

## Command Line

The crate also builds a `command-stream` binary:

```bash
cargo run -- echo hello
```

## TUI Capture

`capture_terminal` runs an interactive program in a real pseudoterminal and
uses `vt100` to retain settled terminal states:

```rust
use command_stream::terminal::{
    capture_terminal, TerminalCaptureOptions, TerminalInteraction, TerminalKey,
};

let capture = capture_terminal(TerminalCaptureOptions {
    file: "codex".into(),
    args: vec!["--no-alt-screen".into()],
    interactions: vec![TerminalInteraction {
        after_regex: Some("Ready: .+".into()),
        idle_duration: std::time::Duration::from_millis(50),
        text: Some("Inspect the failing test".into()),
        key: Some(TerminalKey::Enter),
        ..TerminalInteraction::default()
    }],
    artifact_directory: Some("artifacts/codex".into()),
    ..TerminalCaptureOptions::default()
})?;

println!("{}", capture.transcript);
# Ok::<(), command_stream::terminal::TerminalCaptureError>(())
```

The capture contains the raw PTY output, consecutive-deduplicated frames, an
ordered unrolled transcript, and asciicast v2 input/output/resize events. The
artifact directory receives `transcript.txt`, `frames.json`, `session.cast`,
`snapshot.svg`, and an animated `recording.svg`; timeout errors retain the
partial capture and those diagnostic files. Use `capture_terminal_async` from
an async application.

### Interactive sessions

`capture_terminal` is batch-only: every interaction is known up front and
`timeout` (30 s by default) kills the child. When the input arrives later and
from elsewhere — an authorization code a human pastes back minutes later, a
chat-ops bridge, a test that interleaves assertions with input — use
`open_terminal`, which keeps the same PTY open until you close it:

```rust,no_run
use command_stream::terminal::{
    open_terminal, TerminalCaptureOptions, TerminalInteraction, TerminalKey, TerminalPattern,
};
use std::time::Duration;

let mut session = open_terminal(TerminalCaptureOptions {
    file: "my-cli".into(),
    args: vec!["login".into()],
    ..TerminalCaptureOptions::default()
})?;

session.wait_for(
    &TerminalPattern::regex(r"https://\S+")?,
    Duration::from_millis(50),
    None,
)?;
let url = session.transcript();

// ... arbitrary time passes; nothing terminates the child ...

session.send(&TerminalInteraction {
    text: Some("ABCD-1234".into()),
    key: Some(TerminalKey::Enter),
    ..TerminalInteraction::default()
})?;
session.wait_for(&TerminalPattern::text("Logged in"), Duration::ZERO, None)?;

let capture = session.close()?;
# let _ = (url, capture);
# Ok::<(), command_stream::terminal::TerminalCaptureError>(())
```

`open_terminal` accepts every `capture_terminal` option and forces
`timeout: None`, so a session runs until the child exits or `close()` is called.
`wait_for` reuses the same readiness semantics as `interactions`, including the
idle wait, and fails when the child exits first or its own timeout elapses.
`send` uses the `TerminalInteraction` vocabulary, `close` stops the child and
returns the usual capture (writing `artifact_directory` artifacts), and `finish`
waits for a child that exits on its own. `capture_terminal` is implemented on
top of the same session, so the two paths cannot drift.

Interactions can wait for literal output with `after`, regex output with
`after_regex`, and output quiescence with `idle_duration`. Named
`TerminalKey` variants cover arrows, Enter, Tab, Escape, Backspace, Ctrl-C, and
Ctrl-D; use `TerminalKey::Raw` for any other escape sequence.

## Features

- Shell parser for pipelines, command lists, logical operators, and redirection.
- Built-in command implementations for file-system and shell utility commands.
- Async execution with `tokio`.
- Virtual command abstractions for embedding command behavior in Rust programs.
- Cross-platform tests covering parser, state, events, streams, and built-ins.

## Development

```bash
cargo fmt --all -- --check
cargo clippy --all-targets --all-features
cargo test --all-features
cargo test --doc --all-features
cargo package --allow-dirty
```

Rust release automation lives in [scripts/](scripts/) and is controlled by
`.github/workflows/rust.yml` from the repository root.