command-stream 1.10.1

Modern shell command execution library with streaming, async iteration, and event support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
# command-stream for Rust

The isolated `command_stream::execa` module and crate-root `execa`,
`execa_sync`, `execa_node`, and `execa_compat` functions provide portable
exact-argv execution, byte input/output, streaming, errors and cancellation.
See the [Execa migration guide](../js/docs/EXECA_MIGRATION.md#rust) for API
examples and the differences from JavaScript Execa.

[![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.

The shared advanced APIs include `CommandBuilder`, `DataStream`,
`terminal::TerminalBuffer`, and `CommandEnvironment`. See the
[advanced workflows guide](../js/docs/ADVANCED-WORKFLOWS.md#rust-equivalents) and
`examples/language_features.rs` for executable examples.
`command-stream fy input.sh output.mjs` generates an interpreter-backed Node module
using `sh` and refuses to overwrite existing output.

## ShellJS compatibility

`command_stream::shelljs::ShellJs` provides asynchronous command calls with
session-local directory state. Native head, tail, sort and uniq bring the
built-in catalog to 26 commands. See the [migration guide](../js/docs/SHELLJS_MIGRATION.md)
and [runnable example](./examples/shelljs_migration.rs) for supported options and
JavaScript translation limits.

## 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);

    // `error_for_status()` turns a failing result into an error, whose status
    // reads through the same pair of names.
    let error = CommandResult::error_with_code("boom", 2)
        .error_for_status()
        .unwrap_err();
    assert_eq!(error.code(), Some(2));
    assert_eq!(error.exit_code(), error.code());
}
```

### Successful CLI output on stderr

Command-stream preserves the file descriptor chosen by the child process. A
zero exit code can therefore accompany an empty `stdout` and useful `stderr`.
Check both when a CLI version may print a machine-readable result, such as a
new pull request URL, to stderr:

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

# async fn example() -> Result<(), command_stream::Error> {
let result = run("gh pr create --fill").await?;
let pull_request_url = result
    .stdout
    .lines()
    .chain(result.stderr.lines())
    .find(|line| line.starts_with("https://github.com/"));
# let _ = pull_request_url;
# Ok(())
# }
```

Append `2>&1` to the command when normal shell stream merging is preferred. The
merged output is captured in `stdout`, while `stderr` is empty.

## Streaming

On Windows, killing a process runner or cancelling/dropping a streaming command
stops its descendants with `taskkill /PID <pid> /T /F` before the parent exits.
Termination is immediate; the signal grace period applies to POSIX platforms.
If `taskkill` fails, termination falls back to the direct child.

Completed `CommandResult` values expose readable `stdout` and `stderr`
snapshots (`std::io::Read`) and a writable `stdin` record (`std::io::Write`).
String methods and formatting remain available on the output snapshots. Reading
advances a byte cursor; call `rewind()` to read again. For a running child,
configure `StdinOption::Pipe` and call `ProcessRunner::write_stdin`, then
`close_stdin`. Writes to `result.stdin` after completion update only the record.

```rust,no_run
use command_stream::{exec, RunOptions};
use std::io::Read;

# async fn example() -> Result<(), command_stream::Error> {
let mut result = exec("echo hello", RunOptions { mirror: false, ..RunOptions::default() }).await?;
let mut output = String::new();
result.stdout.read_to_string(&mut output)?;
assert_eq!(output.trim(), "hello");
assert_eq!(result.stdout.trim(), "hello");
# Ok(())
# }
```

`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`.

For synchronous callers, the same exact-argument builder supports
`collect_blocking()`:

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

fn main() -> command_stream::Result<()> {
    let result = StreamingRunner::from_argv("git", ["status", "--short"])
        .collect_blocking()?;
    assert!(result.is_success());
    Ok(())
}
```

Configure `cwd`, `env`, and `stdin` on the builder before collecting. Call
`collect_blocking()` outside a Tokio runtime; async code uses `collect().await`.
The [cross-spawn migration guide](../js/docs/CROSS_SPAWN_MIGRATION.md) maps ten
common process use cases to both language implementations.

## Live Stdin

`StreamingRunner::stdin_pipe()` keeps the child's stdin open while the output
is streamed, so input can be sent in reaction to output (issue #230). Write
with `OutputStream::write_stdin` and finish with `close_stdin` so the child
reads end-of-file; dropping the stream closes stdin too. Content passed to
`stdin` or `stdin_bytes` is still written first.

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

# async fn example() -> command_stream::Result<()> {
let mut stream = StreamingRunner::from_argv("cat", Vec::<String>::new())
    .stdin_pipe()
    .stream();
stream.write_stdin(b"ping\n").await?;
while let Some(chunk) = stream.next().await {
    if let OutputChunk::Stdout(data) = chunk {
        assert_eq!(data, b"ping\n");
        stream.close_stdin().await?; // the child sees EOF and exits
    }
}
# Ok(())
# }
```

`ProcessRunner::from_argv(program, args, options)` is the exact-argv
counterpart of `ProcessRunner::new`: nothing is parsed, expanded or quoted and
virtual commands are never used. Combined with `StdinOption::Pipe` it drives a
long-lived child over stdin, e.g. a JSON-lines protocol:

```rust,no_run
use command_stream::{ProcessRunner, RunOptions, StdinOption};

# async fn example() -> command_stream::Result<()> {
let mut runner = ProcessRunner::from_argv(
    "my-agent",
    ["--input-format", "stream-json"],
    RunOptions { stdin: StdinOption::Pipe, mirror: false, ..Default::default() },
);
runner.start().await?;
runner.write_stdin(b"{\"type\":\"user\"}\n").await?;
runner.close_stdin().await?;
let result = runner.run().await?;
println!("{}", result.stdout);
# Ok(())
# }
```

## Removing Environment Variables

`env` adds or overrides variables; `env_remove` drops inherited ones, the
equivalent of `env -u NAME` (issue #229). Removals are applied after `env` and
`clear_env`, so a removed name is absent even when the `env` map sets it.

```rust,no_run
use command_stream::{exec, RunOptions, StreamingRunner};

# async fn example() -> command_stream::Result<()> {
// Builder form: one name at a time, or several with `env_remove_all`.
let result = StreamingRunner::from_argv("printenv", ["CLAUDECODE"])
    .env_remove("CLAUDECODE")
    .env_remove_all(["CLAUDE_CODE_ENTRYPOINT"])
    .collect()
    .await?;
assert_eq!(result.code, 1); // printenv: variable not set

// Options form for `exec`, `ProcessRunner` and the macros' runners.
let options = RunOptions { env_remove: vec!["CLAUDECODE".into()], ..Default::default() };
let result = exec("printenv CLAUDECODE", options).await?;
assert_eq!(result.code, 1);
# Ok(())
# }
```

`TerminalCaptureOptions` accepts the same `env_remove` list, plus `env_clear`
to start from an empty environment (see [TUI Capture](#tui-capture)).

## Process ID of a Running Command

`pid()` is the id of the operating system process behind a command. It is
recorded when the process is spawned, so — unlike the child handle, which `run()`
consumes in order to await it — it stays readable after the command has finished
(issue #18). It mirrors the JavaScript `runner.pid` property
([JS process id documentation](../js/README.md#process-id-of-a-running-command)).

```rust,no_run
use command_stream::{ProcessRunner, RunOptions};

#[tokio::main]
async fn main() -> command_stream::Result<()> {
    let mut runner = ProcessRunner::new("/bin/sleep 5", RunOptions::default());
    assert_eq!(runner.pid(), None); // nothing has been spawned yet

    runner.start().await?;
    println!("running as {:?}", runner.pid()); // Some(51234)

    runner.kill()?;
    runner.run().await?;

    println!("still readable: {:?}", runner.pid()); // Some(51234)
    Ok(())
}
```

### Streaming commands

`StreamingRunner` spawns its child inside a background task, so the id is not
known the moment `stream()` returns. `wait_for_pid()` waits for the spawn to
complete; `pid()` reports whatever is known right now, without waiting:

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

#[tokio::main]
async fn main() {
    let mut stream = StreamingRunner::new("/bin/sleep 30").stream();

    assert_eq!(stream.pid(), None); // the spawn has not happened yet

    let pid = stream.wait_for_pid().await.expect("the child was spawned");
    println!("running as {pid}");

    stream.kill();
    while stream.next().await.is_some() {}
}
```

Both return `None` if the spawn fails, so `wait_for_pid()` never waits forever
for a process that will not exist.

### What the id names

A command string is handed to a shell, so the id names **the process that shell
put there**. Usually that is the shell itself, with the command running as its
child:

```console
$ ps -o args= -p 51234
/bin/sh -c /bin/sleep 5
```

Some shells replace themselves with the command when the string is a single
simple command, so the same id can name the command directly instead — macOS
`/bin/sh` does this, where the line above reads `/bin/sleep 5`. Do not depend on
either shape; what holds everywhere is that the id names the process the library
spawned to run your command.

The spawned process leads its own process group, so the group id equals the
pid. That is what lets `kill()` reach the command underneath the
wrapper (see [Grandchildren and process groups](#grandchildren-and-process-groups)).

A consequence worth knowing: a command that does not exist is reported by the
shell that looked for it, so there is still an id even though nothing you asked
for ran. The failure code is the shell's convention rather than the library's:
POSIX shells use `127`, "command not found", while `cmd.exe` exits with `1`.

To get the id of the command itself, with no shell in between, use
[`StreamingRunner::from_argv`](#streaming), which bypasses `/bin/sh -c` and
`cmd.exe /c` entirely. With no shell to fall back on, a missing executable is
then a failed spawn, and the id stays `None`.

### Built-in commands have no id

Built-in commands such as `echo`, `sleep` and `cat` run inside your process and
never spawn anything, so there is no operating system process to identify and
`pid()` stays `None`:

```rust,no_run
use command_stream::{ProcessRunner, RunOptions};

#[tokio::main]
async fn main() -> command_stream::Result<()> {
    let mut builtin = ProcessRunner::new("echo hello", RunOptions::default());
    builtin.run().await?;
    assert_eq!(builtin.pid(), None);

    // The absolute path bypasses the built-in, so a real process is spawned.
    let mut external = ProcessRunner::new("/bin/echo hello", RunOptions::default());
    external.run().await?;
    assert!(external.pid().is_some());

    Ok(())
}
```

A runnable walkthrough of all of the above is in
[`rust/examples/process_pid_access.rs`](examples/process_pid_access.rs), which
can be run with `cargo run --example process_pid_access`.

## Child Handle of a Running Command

After `start().await`, `child()` borrows the active operating-system child. Its
`kill()` and `kill_with(signal)` methods use the same process-group delivery,
grace period, and forceful escalation as the runner itself:

```rust,no_run
use command_stream::{ProcessRunner, RunOptions};

#[tokio::main]
async fn main() -> command_stream::Result<()> {
    let mut runner = ProcessRunner::new("/bin/sleep 30", RunOptions::default());
    runner.start().await?;

    if let Some(mut child) = runner.child() {
        println!("child pid: {:?}", child.pid());
        child.kill_with("SIGTERM")?;
    }

    let _ = runner.run().await?;
    assert!(runner.child().is_none()); // run() consumed and reaped it
    Ok(())
}
```

`ProcessChild::native()` and `native_mut()` expose Tokio's child object when a
caller needs a lower-level operation. `child()` returns `None` before startup,
after `run()`, and for built-in commands, which execute in-process. Rust keeps
startup explicit because borrowing a child cannot perform asynchronous work;
for an immediately returned, independently controllable handle, use
`StreamingRunner::stream()` and call `kill()` on its `OutputStream`.

## Signals

`kill()` stops a running command. It defaults to `SIGTERM` and works the same way
for both runners, matching the JavaScript implementation
([JS signal documentation](../js/README.md#sending-signals-to-a-running-command)).

### What `kill()` actually does

Stopping a process is not a single signal. Every kill runs the same four steps:

1. The requested signal is delivered to the child **and its process group**, so a
   grandchild behind a shell wrapper is reached too.
2. The child is given a grace period (`kill_grace_ms`, default `100`) to run its
   own signal handler and exit on its own terms.
3. If it is still alive when the grace period expires, `SIGKILL` follows, so a
   process that ignores the signal is still guaranteed to terminate.
4. The reported exit code is the conventional `128 + signal` value.

Step 2 is what makes a shutdown _graceful_: without it, a child that traps
SIGTERM to flush output, release a lock, or stop its own workers is destroyed
before its handler can run.

### Grandchildren and process groups

Commands run through a shell, so the real work is usually a grandchild of the
`sh` that was spawned. Both runners therefore start the child in its own process
group and signal the group, not just the direct child — including the common
case where the wrapper dies on the first signal and the grandchild is reparented
to init.

The one exception is a command that shares your terminal: when `interactive` is
set, or when stdin is inherited and is a tty, `ProcessRunner` leaves the child in
the caller's process group. It has to, because the terminal delivers CTRL+C to
its foreground group only, and a background child that read from the terminal
would be stopped with SIGTTIN. For those commands the signal reaches the direct
child alone — and CTRL+C from the terminal already reaches the whole group
anyway. Set `stdin` to `StdinOption::Null` or `StdinOption::Pipe` if you need
group delivery from `kill()`.

Group membership is recorded when the child is spawned rather than looked up
when it is signalled, because by then the group leader is usually dead: the
first signal kills the `sh` wrapper, and the escalation follows a grace period
later. macOS refuses to answer `getpgid` for a process in that state, which
would silently skip the delivery and leave the grandchild running.

This is one place where `ProcessRunner` can promise slightly more than the
JavaScript implementation. Because it holds the child until you await it, the
group id stays reserved even after the shell exits, so `kill()` still reaches a
worker the shell left behind. Node and Bun reap the shell immediately, so
JavaScript cannot address that group safely and leaves such a worker running.

### ProcessRunner

`kill()` sends the configured signal; `kill_with(signal)` overrides it for a
single call:

```rust,no_run
use command_stream::{ProcessRunner, RunOptions};

#[tokio::main]
async fn main() -> command_stream::Result<()> {
    let mut runner = ProcessRunner::new(
        "sh -c 'trap \"echo cleaning up; exit 0\" TERM; while true; do sleep 1; done'",
        RunOptions {
            // The signal an argument-less kill() delivers (default SIGTERM).
            kill_signal: "SIGTERM".to_string(),
            // Time the child gets to handle it before SIGKILL (default 100ms).
            kill_grace_ms: 100,
            ..Default::default()
        },
    );

    runner.start().await?;
    runner.kill()?; // the trap runs, prints "cleaning up", and exits
    runner.kill_with("SIGINT")?; // explicit per-call override

    Ok(())
}
```

### StreamingRunner

`stream.kill()` and `stream.kill_with(signal)` stop the process from inside the
loop; dropping the stream (e.g. `break`) stops it too:

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

#[tokio::main]
async fn main() {
    let mut stream = StreamingRunner::new("sh -c 'while true; do echo tick; sleep 0.1; done'")
        .kill_signal("SIGINT") // default for kill() and for dropping the stream
        .kill_grace_ms(100) // grace before the SIGKILL escalation
        .stream();

    while let Some(chunk) = stream.next().await {
        match chunk {
            OutputChunk::Stdout(_) => stream.kill(), // sends SIGINT
            OutputChunk::Exit(code) => println!("exit: {code}"), // 130
            OutputChunk::Stderr(_) => {}
        }
    }
}
```

### Tuning the grace period

`kill_grace_ms` is the number of milliseconds between the requested signal and
the SIGKILL escalation. Set it to `0` to escalate immediately, with no chance to
clean up: the requested signal is then not delivered at all, only `SIGKILL`.
Delivering it first and then killing would leave a window the child can be
scheduled in, which makes "no grace" a race rather than a guarantee. The
reported exit code still reflects the signal you requested.

`SIGKILL` is never delayed: it cannot be caught, so `kill_with("SIGKILL")` skips
the grace period regardless of the configured value.

### Signal exit codes

A process stopped by a signal reports `128 + signal`, the same convention POSIX
shells use. `signal_number` and `signal_exit_code` expose the mapping:

```rust
use command_stream::{signal_exit_code, signal_number};

assert_eq!(signal_number("SIGINT"), 2);
assert_eq!(signal_exit_code("SIGINT"), 130); // CTRL+C
assert_eq!(signal_exit_code("SIGTERM"), 143);
assert_eq!(signal_exit_code("SIGKILL"), 137);
```

| Signal    | Number | Exit code | Typical meaning                      |
| --------- | ------ | --------- | ------------------------------------ |
| `SIGHUP`  | 1      | `129`     | Terminal closed / reload config      |
| `SIGINT`  | 2      | `130`     | CTRL+C                               |
| `SIGQUIT` | 3      | `131`     | Quit from keyboard                   |
| `SIGKILL` | 9      | `137`     | Forced termination, cannot be caught |
| `SIGUSR1` | 10     | `138`     | Application-defined                  |
| `SIGUSR2` | 12     | `140`     | Application-defined                  |
| `SIGTERM` | 15     | `143`     | Polite request to stop (the default) |

The code reflects the signal **you requested**, even when the SIGKILL escalation
is what ultimately stopped the process. Unknown signal names fall back to
`SIGTERM`. Signals are a Unix concept; on Windows the escalation terminates the
process directly.

### Timeouts

`RunOptions::timeout` and `StreamingRunner::timeout` bound a run (issue #227).
When the limit passes, the child is stopped exactly as `kill()` would stop it:
the configured `kill_signal`, then `SIGKILL` after the grace period. The result
carries the `128 + signal` code (143 for the default `SIGTERM`) and
`timed_out()` reports `true`; an explicit kill or a normal exit leaves it
`false`.

```rust,no_run
use command_stream::{ProcessRunner, RunOptions, StreamingRunner};
use std::time::Duration;

# async fn example() -> command_stream::Result<()> {
let mut runner = ProcessRunner::new(
    "sleep 30",
    RunOptions { timeout: Some(Duration::from_millis(200)), ..Default::default() },
);
let result = runner.run().await?;
assert_eq!(result.code, 143);
assert!(runner.timed_out());

let mut stream = StreamingRunner::new("sleep 30")
    .timeout(Duration::from_millis(200))
    .stream();
while stream.next().await.is_some() {}
assert!(stream.timed_out());
# Ok(())
# }
```

## Detached Commands

`StreamingRunner::detach()` and `ProcessRunner::spawn_detached()` start a
command that outlives the caller and return its pid without waiting for it
(issue #232). The child gets null stdio, leads a process group of its own on
Unix (`CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS` on Windows), so a CTRL+C
aimed at the caller's group misses it, and nothing the caller drops kills it. A
background thread reaps it when it exits; if the caller exits first, the child
keeps running. No Tokio runtime is needed.

```rust,no_run
use command_stream::{ProcessRunner, RunOptions, StreamingRunner};

// Exact argv with a working directory and a trimmed environment.
let pid = StreamingRunner::from_argv("my-server", ["--port", "8080"])
    .cwd("/srv/app")
    .env_remove("CLAUDECODE")
    .detach()?;
println!("server started as pid {pid:?}");

// A shell command through ProcessRunner; the runner itself stays unstarted.
let pid = ProcessRunner::new("sleep 60 && touch done", RunOptions::default())
    .spawn_detached()?;
println!("background job pid {pid:?}");
# Ok::<(), command_stream::Error>(())
```

`cwd`, `env`, `clear_env`, `env_remove` and `prefer_local` apply as usual;
stdin content, `stdin_pipe`, `timeout` and the kill settings do not, since
nothing supervises a detached child. The JavaScript `spawnDetached()` makes the
child a session leader (`setsid`) rather than only a process-group leader; both
keep it out of the caller's terminal signals.

## Multiline Text and Exact Output

The command macros treat an interpolated multiline string as one literal
argument. Shell metacharacters remain data, and captured stdout/stderr preserve
whether the child emitted a final newline:

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

# async fn example() -> Result<(), command_stream::Error> {
let content = "# Generated\n\nLiteral: `code`, $HOME, ${name}, and C:\\Tools";
let result = s!("printf '%s' {}", content).await?;
assert_eq!(result.stdout, content);
# Ok(())
# }
```

Use `printf '%s'` instead of `echo` when exact text matters; `echo` normally
adds a trailing newline. If no command is involved, prefer `std::fs::write`.

## GitHub CLI Markdown Bodies

The same literal-argument contract applies to complex issue bodies. No manual
escaping is needed for fenced code, `${...}` text, quotes, shell-looking
syntax, backslashes, newlines, or Unicode:

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

# async fn example() -> Result<(), command_stream::Error> {
let repository = "owner/repository";
let title = "Bug report";
let body = "## Reproduction\n\n```rust\nlet message = \"literal ${value}\";\n```\n\n\
            $HOME and $(whoami) are documentation, not shell syntax.";

let result = s!(
    "gh issue create --repo {} --title {} --body {}",
    repository,
    title,
    body,
)
.await?;
assert!(result.is_success());
# Ok(())
# }
````

If the text already lives in a file, use GitHub CLI's `--body-file` option.
For platform-native argument handling without a shell, pass the same values to
`StreamingRunner::from_argv`.

## Command Line

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

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

## TUI Capture

Independent buffers and live sessions support full frame subscriptions and
replayable ANSI change subscriptions with sequence numbers. Sessions expose
`snapshot()`, `text(color)` and `poll()`; capture options include
`frames_on_write` and `recording`. See the
[virtual terminal guide](../js/docs/VIRTUAL_TERMINAL.md#rust) for receiver lag
handling and using the synchronous session driver with an async consumer.

`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`, an animated `recording.svg` and an animated `recording.gif`
drawn with 8x8 bitmap glyphs; timeout errors retain the partial capture and
those diagnostic files. Use `capture_terminal_async` from an async application.

`artifacts` selects which of those files are written (`None` writes all of
`TerminalArtifact::ALL`), and the renderers of unselected files are skipped
(issue #231). `aspect_ratio` derives `rows` from `cols` as
`max(1, round(cols / (2 * aspect_ratio)))`, like the JavaScript `aspectRatio`
option. `on_trace` receives `output`, `interaction-scheduled`,
`interaction-applied`, `timeout` and `exit` events as they happen.
`env_remove` and `env_clear` trim the child's environment (issue #229);
`TERM`, `COLUMNS`, `LINES` and `env` are still set after `env_clear`.

```rust,no_run
use command_stream::terminal::{
    capture_terminal, TerminalArtifact, TerminalCaptureOptions, TerminalTraceCallback,
};

let capture = capture_terminal(TerminalCaptureOptions {
    file: "claude".into(),
    env_remove: vec!["CLAUDECODE".into()],
    cols: 120,
    aspect_ratio: Some(16.0 / 9.0),
    artifact_directory: Some("artifacts/claude".into()),
    artifacts: Some(vec![TerminalArtifact::Transcript, TerminalArtifact::Gif]),
    on_trace: Some(TerminalTraceCallback::new(|trace| {
        eprintln!("{:.3}s {}", trace.time, trace.event.kind());
    })),
    ..TerminalCaptureOptions::default()
})?;
println!("{} frames", capture.frames.len());
# Ok::<(), command_stream::terminal::TerminalCaptureError>(())
```

### 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. An interaction
must contain at least one action or wait; an empty `TerminalInteraction` is
rejected before the terminal is opened or input is sent.

### Built-in `tee`

`tee` copies its input to stdout and to every file it is given, so a pipeline
can be recorded and keep flowing. It follows GNU coreutils: `-a`/`--append`
appends instead of truncating, `-i`/`--ignore-interrupts` keeps writing when
the pipeline is cancelled, `--` ends option parsing, and a bare `-` is a file
named `-` rather than stdout. A write failure is reported on stderr and sets
exit code 1, while the remaining files are still written.

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

#[tokio::main]
async fn main() {
    let result = Pipeline::new()
        .add("echo deploying")
        .add("tee deploy.log")
        .run()
        .await
        .expect("pipeline should run");

    assert_eq!(result.stdout, "deploying\n");
}
```

Built-in commands receive their stdin as one completed buffer, because a
pipeline reads each upstream stage to the end before handing the result on. So
`tee` is a pipeline stage, not a live terminal filter; for an interactive `tee`,
use the PTY sessions described under [Interactive sessions](#interactive-sessions).

## Features

### Tracked compatibility corpus

The Rust tests track the native process API plus Tokio, async-process,
assert_cmd, duct, xshell, subprocess, rust_cmd_lib, run_script, bkt,
rust-shell, shellfn, rexpect, and expectrl. All fourteen upstream suites are
pinned to immutable commits. Portable public behavior runs against
command-stream, while unsupported capabilities and inapplicable
competitor-specific tests are accounted for in the
[competitor test corpus audit](docs/COMPETITOR_TEST_AUDIT.md).

Run the focused executable corpus with
`cargo test --test competitor_compatibility`.

The [Rust benchmark playground](benchmarks/README.md) turns six of those native
process-library mappings into validated performance, crate-footprint,
feature-coverage, and real-world comparisons. Its CI-sized profile is:

```bash
cargo run --release --locked --manifest-path benchmarks/Cargo.toml -- --smoke
```

- 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

For the native Rust REPL and Cargo watch/restart commands, see the
[interactive development guide](../js/docs/INTERACTIVE-DEVELOPMENT.md).

```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.