Repository: sindresorhus/execa
Pull request #533 (2023-03-06 02:11:53)

Add more documentation for `$`
This PR adds more documentation for the `$` scripting interface.

In particular, it presents its differences with using either Bash or zx. 

Please note that some of the features being documented are related to open PRs still being discussed.

[Direct link](https://github.com/sindresorhus/execa/blob/scripts-doc/docs/scripts.md) to documentation file in Markdown.

cc @aaronccasanova for code review.


Patch:
diff --git a/docs/scripts.md b/docs/scripts.md
new file mode 100644
index 0000000..0cfc43c
--- /dev/null
+++ b/docs/scripts.md
@@ -0,0 +1,656 @@
+# Node.js scripts
+
+With Execa, you can write scripts with Node.js instead of a shell language. It is [secure](#escaping), [performant](#performance), [simple](#simplicity) and [cross-platform](#shell).
+
+```js
+import {$} from 'execa';
+
+const {stdout: name} = await $`cat package.json`
+  .pipeStdout($`grep name`);
+console.log(name);
+
+const branch = await $`git branch --show-current`;
+await $`dep deploy --branch=${branch}`;
+
+await Promise.all([
+  $`sleep 1`,
+  $`sleep 2`,
+  $`sleep 3`,
+]);
+
+const dirName = 'foo bar';
+await $`mkdir /tmp/${dirName}`;
+```
+
+## Summary
+
+This file describes the differences between Bash, Execa, and [zx](https://github.com/google/zx) (which inspired this feature).
+
+### Flexibility
+
+Unlike shell languages like Bash, libraries like Execa and zx enable you to write scripts with a more featureful programming language (JavaScript). This allows complex logic (such as [parallel execution](#parallel-commands)) to be expressed easily. This also lets you use [any Node.js package](#builtin-utilities).
+
+### Shell
+
+The main difference between Execa and zx is that Execa does not require any shell. Shell-specific keywords and features are [written in JavaScript](#variable-substitution) instead.
+
+This is more cross-platform. For example, your code works the same on Windows machines without Bash installed.
+
+Also, there is no shell syntax to remember: everything is just plain JavaScript.
+
+If you really need a shell though, the [`shell` option](../readme.md#shell) can be used.
+
+### Simplicity
+
+Execa's scripting API mostly consists of only two methods: [`` $`command` ``](../readme.md#command) and [`$(options)`](../readme.md#options).
+
+[No special binary](#main-binary) is recommended, no [global variable](#global-variables) is injected: scripts are regular Node.js files.
+
+Execa is a thin wrapper around the core Node.js [`child_process` module](https://nodejs.org/api/child_process.html). Unlike zx, it lets you use [any of its native features](#background-processes): [`pid`](#pid), [IPC](https://nodejs.org/api/child_process.html#subprocesssendmessage-sendhandle-options-callback), [`unref()`](https://nodejs.org/api/child_process.html#subprocessunref), [`detached`](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options), [`uid`](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options), [`gid`](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options), [`signal`](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options), etc.
+
+### Modularity
+
+zx includes many builtin utilities: `fetch()`, `question()`, `sleep()`, `stdin()`, `retry()`, `spinner()`, `chalk`, `fs-extra`, `os`, `path`, `globby`, `yaml`, `minimist`, `which`, Markdown scripts, remote scripts.
+
+Execa does not include [any utility](#builtin-utilities): it focuses on being small and modular instead. Any Node.js package can be used in your scripts.
+
+### Performance
+
+Spawning a shell for every command comes at a performance cost, which Execa avoids.
+
+Also, [local binaries](#local-binaries) can be directly executed without using `npx`.
+
+### Debugging
+
+Child processes can be hard to debug, which is why Execa includes a [`verbose` option](#verbose-mode).
+
+Also, Execa's error messages and [properties](#errors) are very detailed to make it clear to determine why a process failed.
+
+Finally, unlike Bash and zx, which are stateful (options, current directory, etc.), Execa is [purely functional](#current-directory), which also helps with debugging.
+
+## Details
+
+### Main binary
+
+```sh
+# Bash
+bash file.sh
+```
+
+```js
+// zx
+zx file.js
+
+// or a shebang can be used:
+//   #!/usr/bin/env zx
+```
+
+```js
+// Execa scripts are just regular Node.js files
+node file.js
+```
+
+### Global variables
+
+```js
+// zx
+await $`echo example`;
+```
+
+```js
+// Execa
+import {$} from 'execa';
+
+await $`echo example`;
+```
+
+### Command execution
+
+```sh
+# Bash
+echo example
+```
+
+```js
+// zx
+await $`echo example`;
+```
+
+```js
+// Execa
+await $`echo example`;
+```
+
+### Subcommands
+
+```sh
+# Bash
+echo "$(echo example)"
+```
+
+```js
+// zx
+const example = await $`echo example`;
+await $`echo ${example}`;
+```
+
+```js
+// Execa
+const example = await $`echo example`;
+await $`echo ${example}`;
+```
+
+### Parallel commands
+
+```sh
+# Bash
+echo one &
+echo two &
+```
+
+```js
+// zx
+await Promise.all([$`echo one`, $`echo two`]);
+```
+
+```js
+// Execa
+await Promise.all([$`echo one`, $`echo two`]);
+```
+
+### Serial commands
+
+```sh
+# Bash
+echo one && echo two
+```
+
+```js
+// zx
+await $`echo one && echo two`;
+```
+
+```js
+// Execa
+await $`echo one`;
+await $`echo two`;
+```
+
+### Local binaries
+
+```sh
+# Bash
+npx tsc --version
+```
+
+```js
+// zx
+await $`npx tsc --version`;
+```
+
+```js
+// Execa
+await $`tsc --version`;
+```
+
+### Builtin utilities
+
+```js
+// zx
+const content = await stdin();
+```
+
+```js
+// Execa
+import getStdin from 'get-stdin';
+
+const content = await getStdin();
+```
+
+### Variable substitution
+
+```sh
+# Bash
+echo $LANG
+```
+
+```js
+// zx
+await $`echo $LANG`;
+```
+
+```js
+// Execa
+await $`echo ${process.env.LANG}`;
+```
+
+### Environment variables
+
+```sh
+# Bash
+EXAMPLE=1 example_command
+```
+
+```js
+// zx
+$.env.EXAMPLE = '1';
+await $`example_command`;
+delete $.env.EXAMPLE;
+```
+
+```js
+// Execa
+await $({env: {EXAMPLE: '1'}})`example_command`;
+```
+
+### Escaping
+
+```sh
+# Bash
+echo 'one two'
+```
+
+```js
+// zx
+await $`echo ${'one two'}`;
+```
+
+```js
+// Execa
+await $`echo ${'one two'}`;
+```
+
+### Escaping multiple arguments
+
+```sh
+# Bash
+echo 'one two' '$'
+```
+
+```js
+// zx
+await $`echo ${['one two', '$']}`;
+```
+
+```js
+// Execa
+await $`echo ${['one two', '$']}`;
+```
+
+### Current filename
+
+```sh
+# Bash
+echo "$(basename "$0")"
+```
+
+```js
+// zx
+await $`echo ${__filename}`;
+```
+
+```js
+// Execa
+import {fileURLToPath} from 'node:url';
+import path from 'node:path';
+
+const __filename = path.basename(fileURLToPath(import.meta.url));
+
+await $`echo ${__filename}`;
+```
+
+### Verbose mode
+
+```sh
+# Bash
+set -v
+echo example
+```
+
+```js
+// zx >=8
+await $`echo example`.verbose();
+
+// or:
+$.verbose = true;
+```
+
+```js
+// Execa
+const $$ = $({verbose: true});
+await $$`echo example`;
+```
+
+Or:
+
+```
+NODE_DEBUG=execa node file.js
+```
+
+### Current directory
+
+```sh
+# Bash
+cd project
+```
+
+```js
+// zx
+cd('project');
+
+// or:
+$.cwd = 'project';
+```
+
+```js
+// Execa
+const $$ = $({cwd: 'project'});
+```
+
+### Multiple current directories
+
+```sh
+# Bash
+pushd project
+pwd
+popd
+pwd
+```
+
+```js
+// zx
+within(async () => {
+	cd('project');
+	await $`pwd`;
+});
+
+await $`pwd`;
+```
+
+```js
+// Execa
+await $({cwd: 'project'})`pwd`;
+await $`pwd`;
+```
+
+### Exit codes
+
+```sh
+# Bash
+false
+echo $?
+```
+
+```js
+// zx
+const {exitCode} = await $`false`.nothrow();
+echo`${exitCode}`;
+```
+
+```js
+// Execa
+const {exitCode} = await $({reject: false})`false`;
+console.log(exitCode);
+```
+
+### Timeouts
+
+```sh
+# Bash
+timeout 5 echo example
+```
+
+```js
+// zx
+await $`echo example`.timeout('5s');
+```
+
+```js
+// Execa
+await $({timeout: 5000})`echo example`;
+```
+
+### PID
+
+```sh
+# Bash
+echo example &
+echo $!
+```
+
+```js
+// zx does not return `childProcess.pid`
+```
+
+```js
+// Execa
+const {pid} = $`echo example`;
+```
+
+### Errors
+
+```sh
+# Bash communicates errors only through the exit code and stderr
+timeout 1 sleep 2
+echo $?
+```
+
+```js
+// zx
+const {
+	stdout,
+	stderr,
+	exitCode,
+	signal,
+} = await $`sleep 2`.timeout('1s');
+// file:///home/me/Desktop/node_modules/zx/build/core.js:146
+//             let output = new ProcessOutput(code, signal, stdout, stderr, combined, message);
+//                          ^
+// ProcessOutput [Error]:
+//     at file:///home/me/Desktop/example.js:2:20
+//     exit code: null
+//     signal: SIGTERM
+//     at ChildProcess.<anonymous> (file:///home/me/Desktop/node_modules/zx/build/core.js:146:26)
+//     at ChildProcess.emit (node:events:512:28)
+//     at maybeClose (node:internal/child_process:1098:16)
+//     at Socket.<anonymous> (node:internal/child_process:456:11)
+//     at Socket.emit (node:events:512:28)
+//     at Pipe.<anonymous> (node:net:316:12)
+//     at Pipe.callbackTrampoline (node:internal/async_hooks:130:17) {
+//   _code: null,
+//   _signal: 'SIGTERM',
+//   _stdout: '',
+//   _stderr: '',
+//   _combined: ''
+// }
+```
+
+```js
+// Execa
+const {
+  stdout,
+  stderr,
+  exitCode,
+  signal,
+  signalDescription,
+  originalMessage,
+  shortMessage,
+  command,
+  escapedCommand,
+  failed,
+  timedOut,
+  isCanceled,
+  killed,
+	// and other error-related properties: code, etc.
+} = await $({timeout: 1})`sleep 2`;
+// file:///home/me/code/execa/lib/kill.js:60
+// 	reject(Object.assign(new Error('Timed out'), {timedOut: true, signal}));
+// 	                     ^
+// Error: Command timed out after 1 milliseconds: sleep 2
+// Timed out
+//     at file:///home/me/Desktop/example.js:2:20
+//   timedOut: true,
+//   signal: 'SIGTERM',
+//   originalMessage: 'Timed out',
+//   shortMessage: 'Command timed out after 1 milliseconds: sleep 2\nTimed out',
+//   command: 'sleep 2',
+//   escapedCommand: 'sleep 2',
+//   exitCode: undefined,
+//   signalDescription: 'Termination',
+//   stdout: '',
+//   stderr: '',
+//   failed: true,
+//   isCanceled: false,
+//   killed: false
+// }
+```
+
+### Shared options
+
+```sh
+# Bash
+options="timeout 5"
+$options echo one
+$options echo two
+$options echo three
+```
+
+```js
+// zx
+const timeout = '5s';
+await $`echo one`.timeout(timeout);
+await $`echo two`.timeout(timeout);
+await $`echo three`.timeout(timeout);
+```
+
+```js
+// Execa
+const $$ = $({timeout: 5000});
+await $$`echo one`;
+await $$`echo two`;
+await $$`echo three`;
+```
+
+### Background processes
+
+```sh
+# Bash
+echo one &
+```
+
+```js
+// zx does not allow setting the `detached` option
+```
+
+```js
+// Execa
+await $({detached: true})`echo one`;
+```
+
+### Printing to stdout
+
+```sh
+# Bash
+echo example
+```
+
+```js
+// zx
+echo`example`;
+```
+
+```js
+// Execa
+console.log('example');
+```
+
+### Piping stdout to another command
+
+```sh
+# Bash
+echo example | cat
+```
+
+```js
+// zx
+await $`echo example | cat`;
+```
+
+```js
+// Execa
+await $`echo example`.pipeStdout($`cat`);
+```
+
+### Piping stdout and stderr to another command
+
+```sh
+# Bash
+echo example |& cat
+```
+
+```js
+// zx
+const echo = $`echo example`;
+const cat = $`cat`;
+echo.pipe(cat)
+echo.stderr.pipe(cat.stdin);
+await Promise.all([echo, cat]);
+```
+
+```js
+// Execa
+await $`echo example`.pipeAll($`cat`);
+```
+
+### Piping stdout to a file
+
+```sh
+# Bash
+echo example > file.txt
+```
+
+```js
+// zx
+await $`echo example`.pipe(fs.createWriteStream('file.txt'));
+```
+
+```js
+// Execa
+await $`echo example`.pipeStdout('file.txt');
+```
+
+### Piping stdin from a file
+
+```sh
+# Bash
+echo example < file.txt
+```
+
+```js
+// zx
+const cat = $`cat`
+fs.createReadStream('file.txt').pipe(cat.stdin)
+await cat
+```
+
+```js
+// Execa
+await $`cat`.pipeStdin('file.txt');
+```
+
+### Silent stderr
+
+```sh
+# Bash
+echo example 2> /dev/null
+```
+
+```js
+// zx
+await $`echo example`.stdio('inherit', 'pipe', 'ignore');
+```
+
+```js
+// Execa does not forward stdout/stderr by default
+await $`echo example`;
+```
diff --git a/index.d.ts b/index.d.ts
index 7aa4a67..6c45b62 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,6 +1,6 @@
 import {type Buffer} from 'node:buffer';
 import {type ChildProcess} from 'node:child_process';
-import {type Stream, type Readable as ReadableStream} from 'node:stream';
+import {type Stream, type Readable as ReadableStream, type Writable as WritableStream} from 'node:stream';
 
 export type StdioOption =
 	| 'pipe'
@@ -244,6 +244,15 @@ export type CommonOptions<EncodingType> = {
 	@default true
 	*/
 	readonly windowsHide?: boolean;
+
+	/**
+	Print each command on `stderr` before executing it.
+
+	This can also be enabled by setting the `NODE_DEBUG=execa` environment variable in the current process.
+
+	@default false
+	*/
+	readonly verbose?: boolean;
 };
 
 export type Options<EncodingType = string> = {
@@ -276,7 +285,9 @@ export type NodeOptions<EncodingType = string> = {
 	readonly nodeOptions?: string[];
 } & Options<EncodingType>;
 
-export type ExecaReturnBase<StdoutStderrType> = {
+type StdoutStderrAll = string | Buffer | undefined;
+
+export type ExecaReturnBase<StdoutStderrType extends StdoutStderrAll> = {
 	/**
 	The file and arguments that were run, for logging purposes.
 
@@ -337,7 +348,7 @@ export type ExecaReturnBase<StdoutStderrType> = {
 	signalDescription?: string;
 };
 
-export type ExecaSyncReturnValue<StdoutStderrType = string> = {
+export type ExecaSyncReturnValue<StdoutStderrType extends StdoutStderrAll = string> = {
 } & ExecaReturnBase<StdoutStderrType>;
 
 /**
@@ -350,7 +361,7 @@ The child process fails when:
 - being canceled
 - there's not enough memory or there are already too many child processes
 */
-export type ExecaReturnValue<StdoutStderrType = string> = {
+export type ExecaReturnValue<StdoutStderrType extends StdoutStderrAll = string> = {
 	/**
 	The output of the process with `stdout` and `stderr` interleaved.
 
@@ -368,7 +379,7 @@ export type ExecaReturnValue<StdoutStderrType = string> = {
 	isCanceled: boolean;
 } & ExecaSyncReturnValue<StdoutStderrType>;
 
-export type ExecaSyncError<StdoutStderrType = string> = {
+export type ExecaSyncError<StdoutStderrType extends StdoutStderrAll = string> = {
 	/**
 	Error message when the child process failed to run. In addition to the underlying error message, it also contains some information related to why the child process errored.
 
@@ -389,7 +400,7 @@ export type ExecaSyncError<StdoutStderrType = string> = {
 	originalMessage?: string;
 } & Error & ExecaReturnBase<StdoutStderrType>;
 
-export type ExecaError<StdoutStderrType = string> = {
+export type ExecaError<StdoutStderrType extends StdoutStderrAll = string> = {
 	/**
 	The output of the process with `stdout` and `stderr` interleaved.
 
@@ -416,7 +427,7 @@ export type KillOptions = {
 	forceKillAfterTimeout?: number | false;
 };
 
-export type ExecaChildPromise<StdoutStderrType> = {
+export type ExecaChildPromise<StdoutStderrType extends StdoutStderrAll> = {
 	/**
 	Stream combining/interleaving [`stdout`](https://nodejs.org/api/child_process.html#child_process_subprocess_stdout) and [`stderr`](https://nodejs.org/api/child_process.html#child_process_subprocess_stderr).
 
@@ -439,9 +450,38 @@ export type ExecaChildPromise<StdoutStderrType> = {
 	Similar to [`childProcess.kill()`](https://nodejs.org/api/child_process.html#child_process_subprocess_kill_signal). This used to be preferred when cancelling the child process execution as the error is more descriptive and [`childProcessResult.isCanceled`](#iscanceled) is set to `true`. But now this is deprecated and you should either use `.kill()` or the `signal` option when creating the child process.
 	*/
 	cancel(): void;
+
+	/**
+	[Pipe](https://nodejs.org/api/stream.html#readablepipedestination-options) the child process's `stdout` to `target`, which can be:
+	- Another `execa()` return value
+	- A writable stream
+	- A file path string
+
+	If the `target` is another `execa()` return value, it is returned. Otherwise, the original `execa()` return value is returned. This allows chaining `pipeStdout()` then `await`ing the final result.
+
+	The `stdout` option] must be kept as `pipe`, its default value.
+	*/
+	pipeStdout?<Target extends ExecaChildPromise<StdoutStderrAll>>(target: Target): Target;
+	pipeStdout?(target: WritableStream | string): ExecaChildProcess<StdoutStderrType>;
+
+	/**
+	Like `pipeStdout()` but piping the child process's `stderr` instead.
+
+	The `stderr` option must be kept as `pipe`, its default value.
+	*/
+	pipeStderr?<Target extends ExecaChildPromise<StdoutStderrAll>>(target: Target): Target;
+	pipeStderr?(target: WritableStream | string): ExecaChildProcess<StdoutStderrType>;
+
+	/**
+	Combines both `pipeStdout()` and `pipeStderr()`.
+
+	Either the `stdout` option or the `stderr` option must be kept as `pipe`, their default value. Also, the `all` option must be set to `true`.
+	*/
+	pipeAll?<Target extends ExecaChildPromise<StdoutStderrAll>>(target: Target): Target;
+	pipeAll?(target: WritableStream | string): ExecaChildProcess<StdoutStderrType>;
 };
 
-export type ExecaChildProcess<StdoutStderrType = string> = ChildProcess &
+export type ExecaChildProcess<StdoutStderrType extends StdoutStderrAll = string> = ChildProcess &
 ExecaChildPromise<StdoutStderrType> &
 Promise<ExecaReturnValue<StdoutStderrType>>;
 
@@ -541,9 +581,14 @@ console.log(stdout);
 export function execaCommand(command: string, options?: Options): ExecaChildProcess;
 export function execaCommand(command: string, options?: Options<null>): ExecaChildProcess<Buffer>;
 
-type TemplateExpression = string | number | Array<string | number>;
+type TemplateExpression =
+	| string
+	| number
+	| ExecaReturnValue<string | Buffer>
+	| ExecaSyncReturnValue<string | Buffer>
+	| Array<string | number | ExecaReturnValue<string | Buffer> | ExecaSyncReturnValue<string | Buffer>>;
 
-type Execa$<StdoutStderrType = string> = {
+type Execa$<StdoutStderrType extends StdoutStderrAll = string> = {
 	/**
 	Same as `execa()` except both file and arguments are specified in a single tagged template string. For example, `` $`echo unicorns` `` is the same as `execa('echo', ['unicorns'])`.
 
@@ -551,6 +596,8 @@ type Execa$<StdoutStderrType = string> = {
 
 	The `shell` option must be used if the `command` uses shell-specific features (for example, `&&` or `||`), as opposed to being a simple `file` followed by its `arguments`.
 
+	As a convenience, the result from previous `` $`command` `` or `` $.sync`command` `` calls can be used as template expressions in subsequent commands and `$`/`$.sync` will use the `stdout` value. See the example below `` with results from `$` or `$.sync` `` for more details.
+
 	@returns A [`child_process` instance](https://nodejs.org/api/child_process.html#child_process_class_childprocess), which is enhanced to also be a `Promise` for a result `Object` with `stdout` and `stderr` properties.
 
 	@example <caption>Basic</caption>
@@ -596,6 +643,16 @@ type Execa$<StdoutStderrType = string> = {
 	$({stdio: 'inherit'}).sync`echo rainbows`;
 	//=> 'rainbows'
 	```
+
+	@example <caption>With results from `$` or `$.sync`</caption>
+	```
+	import {$} from 'execa';
+
+	const unicorns = await $`echo unicorns`;
+
+	$({stdio: 'inherit'}).sync`echo ${unicorns} rainbows`;
+	//=> 'unicorns rainbows'
+	```
 	*/
 	(options: Options<undefined>): Execa$<StdoutStderrType>;
 	(options: Options): Execa$;
@@ -635,6 +692,8 @@ It's important to note that quotes, backslashes, and spaces are automatically es
 
 The `shell` option must be used if the `command` uses shell-specific features (for example, `&&` or `||`), as opposed to being a simple `file` followed by its `arguments`.
 
+As a convenience, the result from previous `` $`command` `` or `` $.sync`command` `` calls can be used as template expressions in subsequent commands and `$`/`$.sync` will use the `stdout` value. See the example below `` with results from `$` or `$.sync` `` for more details.
+
 @returns A [`child_process` instance](https://nodejs.org/api/child_process.html#child_process_class_childprocess), which is enhanced to also be a `Promise` for a result `Object` with `stdout` and `stderr` properties.
 
 @example <caption>Basic</caption>
@@ -680,6 +739,16 @@ console.log(stdout);
 $({stdio: 'inherit'}).sync`echo rainbows`;
 //=> 'rainbows'
 ```
+
+@example <caption>With results from `$` or `$.sync`</caption>
+```
+import {$} from 'execa';
+
+const unicorns = await $`echo unicorns`;
+
+$({stdio: 'inherit'}).sync`echo ${unicorns} rainbows`;
+//=> 'unicorns rainbows'
+```
 */
 export const $: Execa$;
 
diff --git a/index.js b/index.js
index bd49574..e5525b9 100644
--- a/index.js
+++ b/index.js
@@ -9,9 +9,11 @@ import onetime from 'onetime';
 import {makeError} from './lib/error.js';
 import {normalizeStdio, normalizeStdioNode} from './lib/stdio.js';
 import {spawnedKill, spawnedCancel, setupTimeout, validateTimeout, setExitHandler} from './lib/kill.js';
+import {addPipeMethods} from './lib/pipe.js';
 import {handleInput, getSpawnedResult, makeAllStream, validateInputSync} from './lib/stream.js';
 import {mergePromise, getSpawnedPromise} from './lib/promise.js';
 import {joinCommand, parseCommand, parseTemplates, getEscapedCommand} from './lib/command.js';
+import {logCommand, verboseDefault} from './lib/verbose.js';
 
 const DEFAULT_MAX_BUFFER = 1000 * 1000 * 100;
 
@@ -44,6 +46,7 @@ const handleArguments = (file, args, options = {}) => {
 		cleanup: true,
 		all: false,
 		windowsHide: true,
+		verbose: verboseDefault,
 		...options,
 	};
 
@@ -76,6 +79,7 @@ export function execa(file, args, options) {
 	const parsed = handleArguments(file, args, options);
 	const command = joinCommand(file, args);
 	const escapedCommand = getEscapedCommand(file, args);
+	logCommand(escapedCommand, parsed.options);
 
 	validateTimeout(parsed.options);
 
@@ -97,7 +101,8 @@ export function execa(file, args, options) {
 			isCanceled: false,
 			killed: false,
 		}));
-		return mergePromise(dummySpawned, errorPromise);
+		mergePromise(dummySpawned, errorPromise);
+		return dummySpawned;
 	}
 
 	const spawnedPromise = getSpawnedPromise(spawned);
@@ -158,13 +163,16 @@ export function execa(file, args, options) {
 
 	spawned.all = makeAllStream(spawned, parsed.options);
 
-	return mergePromise(spawned, handlePromiseOnce);
+	addPipeMethods(spawned);
+	mergePromise(spawned, handlePromiseOnce);
+	return spawned;
 }
 
 export function execaSync(file, args, options) {
 	const parsed = handleArguments(file, args, options);
 	const command = joinCommand(file, args);
 	const escapedCommand = getEscapedCommand(file, args);
+	logCommand(escapedCommand, parsed.options);
 
 	validateInputSync(parsed.options);
 
diff --git a/lib/command.js b/lib/command.js
index d40877f..b6d2e5e 100644
--- a/lib/command.js
+++ b/lib/command.js
@@ -1,3 +1,6 @@
+import {Buffer} from 'node:buffer';
+import {ChildProcess} from 'node:child_process';
+
 const normalizeArgs = (file, args = []) => {
 	if (!Array.isArray(args)) {
 		return [file];
@@ -40,6 +43,39 @@ export const parseCommand = command => {
 	return tokens;
 };
 
+const parseExpression = expression => {
+	const typeOfExpression = typeof expression;
+
+	if (typeOfExpression === 'string') {
+		return expression;
+	}
+
+	if (typeOfExpression === 'number') {
+		return String(expression);
+	}
+
+	if (
+		typeOfExpression === 'object'
+		&& expression !== null
+		&& !(expression instanceof ChildProcess)
+		&& 'stdout' in expression
+	) {
+		const typeOfStdout = typeof expression.stdout;
+
+		if (typeOfStdout === 'string') {
+			return expression.stdout;
+		}
+
+		if (Buffer.isBuffer(expression.stdout)) {
+			return expression.stdout.toString();
+		}
+
+		throw new TypeError(`Unexpected "${typeOfStdout}" stdout in template expression`);
+	}
+
+	throw new TypeError(`Unexpected "${typeOfExpression}" in template expression`);
+};
+
 const parseTemplate = (template, index, templates, expressions) => {
 	const templateString = template ?? templates.raw[index];
 	const templateTokens = templateString.split(SPACES_REGEXP).filter(Boolean);
@@ -51,8 +87,8 @@ const parseTemplate = (template, index, templates, expressions) => {
 	const expression = expressions[index];
 
 	return Array.isArray(expression)
-		? [...templateTokens, ...expression.map(String)]
-		: [...templateTokens, String(expression)];
+		? [...templateTokens, ...expression.map(expression => parseExpression(expression))]
+		: [...templateTokens, parseExpression(expression)];
 };
 
 export const parseTemplates = (templates, expressions) => templates.flatMap(
diff --git a/lib/pipe.js b/lib/pipe.js
new file mode 100644
index 0000000..e73ffcc
--- /dev/null
+++ b/lib/pipe.js
@@ -0,0 +1,42 @@
+import {createWriteStream} from 'node:fs';
+import {ChildProcess} from 'node:child_process';
+import {isWritableStream} from 'is-stream';
+
+const isExecaChildProcess = target => target instanceof ChildProcess && typeof target.then === 'function';
+
+const pipeToTarget = (spawned, streamName, target) => {
+	if (typeof target === 'string') {
+		spawned[streamName].pipe(createWriteStream(target));
+		return spawned;
+	}
+
+	if (isWritableStream(target)) {
+		spawned[streamName].pipe(target);
+		return spawned;
+	}
+
+	if (!isExecaChildProcess(target)) {
+		throw new TypeError('The second argument must be a string, a stream or an Execa child process.');
+	}
+
+	if (!isWritableStream(target.stdin)) {
+		throw new TypeError('The target child process\'s stdin must be available.');
+	}
+
+	spawned[streamName].pipe(target.stdin);
+	return target;
+};
+
+export const addPipeMethods = spawned => {
+	if (spawned.stdout !== null) {
+		spawned.pipeStdout = pipeToTarget.bind(undefined, spawned, 'stdout');
+	}
+
+	if (spawned.stderr !== null) {
+		spawned.pipeStderr = pipeToTarget.bind(undefined, spawned, 'stderr');
+	}
+
+	if (spawned.all !== undefined) {
+		spawned.pipeAll = pipeToTarget.bind(undefined, spawned, 'all');
+	}
+};
diff --git a/lib/promise.js b/lib/promise.js
index 975aee0..a4773f3 100644
--- a/lib/promise.js
+++ b/lib/promise.js
@@ -16,8 +16,6 @@ export const mergePromise = (spawned, promise) => {
 
 		Reflect.defineProperty(spawned, property, {...descriptor, value});
 	}
-
-	return spawned;
 };
 
 // Use promises instead of `child_process` events
diff --git a/lib/verbose.js b/lib/verbose.js
new file mode 100644
index 0000000..5f5490e
--- /dev/null
+++ b/lib/verbose.js
@@ -0,0 +1,19 @@
+import {debuglog} from 'node:util';
+import process from 'node:process';
+
+export const verboseDefault = debuglog('execa').enabled;
+
+const padField = (field, padding) => String(field).padStart(padding, '0');
+
+const getTimestamp = () => {
+	const date = new Date();
+	return `${padField(date.getHours(), 2)}:${padField(date.getMinutes(), 2)}:${padField(date.getSeconds(), 2)}.${padField(date.getMilliseconds(), 3)}`;
+};
+
+export const logCommand = (escapedCommand, {verbose}) => {
+	if (!verbose) {
+		return;
+	}
+
+	process.stderr.write(`[${getTimestamp()}] ${escapedCommand}\n`);
+};
diff --git a/readme.md b/readme.md
index 89625df..5e97e4b 100644
--- a/readme.md
+++ b/readme.md
@@ -13,6 +13,7 @@
 This package improves [`child_process`](https://nodejs.org/api/child_process.html) methods with:
 
 - Promise interface.
+- [Scripts interface](#scripts-interface).
 - [Strips the final newline](#stripfinalnewline) from the output so you don't have to do `stdout.trim()`.
 - Supports [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) binaries cross-platform.
 - [Improved Windows support.](https://github.com/IndigoUnited/node-cross-spawn#why)
@@ -20,6 +21,7 @@ This package improves [`child_process`](https://nodejs.org/api/child_process.htm
 - [Executes locally installed binaries by name.](#preferlocal)
 - [Cleans up spawned processes when the parent process dies.](#cleanup)
 - [Get interleaved output](#all) from `stdout` and `stderr` similar to what is printed on the terminal. [*(Async only)*](#execasyncfile-arguments-options)
+- Convenience methods to [pipe processes' output](#redirect-output-to-a-file)
 - [Can specify file and arguments as a single string without a shell](#execacommandcommand-options)
 - More descriptive errors.
 
@@ -39,7 +41,9 @@ console.log(stdout);
 //=> 'unicorns'
 ```
 
-### Using the tagged templates API
+### Scripts interface
+
+For more information, please see [this page](docs/scripts.md).
 
 #### Basic
 
@@ -90,12 +94,66 @@ $({stdio: 'inherit'}).sync`echo rainbows`;
 //=> 'rainbows'
 ```
 
-### Pipe the child process stdout to the parent
+#### With results from `$` or `$.sync`
+
+```js
+import {$} from 'execa';
+
+const unicorns = await $`echo unicorns`;
+
+$({stdio: 'inherit'}).sync`echo ${unicorns} rainbows`;
+//=> 'unicorns rainbows'
+```
+
+#### Verbose mode
+
+```sh
+> node file.js
+unicorns
+rainbows
+
+> NODE_DEBUG=execa node file.js
+[16:50:03.305] echo unicorns
+unicorns
+[16:50:03.308] echo rainbows
+rainbows
+```
+
+### Redirect output to a file
 
 ```js
 import {execa} from 'execa';
 
-execa('echo', ['unicorns']).stdout.pipe(process.stdout);
+// Similar to `echo unicorns > stdout.txt` in Bash
+await execa('echo', ['unicorns']).pipeStdout('stdout.txt')
+
+// Similar to `echo unicorns 2> stdout.txt` in Bash
+await execa('echo', ['unicorns']).pipeStderr('stderr.txt')
+
+// Similar to `echo unicorns &> stdout.txt` in Bash
+await execa('echo', ['unicorns'], {all:true}).pipeAll('all.txt')
+```
+
+### Save and pipe output from a child process
+
+```js
+import {execa} from 'execa';
+
+const {stdout} = await execa('echo', ['unicorns']).pipeStdout(process.stdout);
+// Prints `unicorns`
+console.log(stdout);
+// Also returns 'unicorns'
+```
+
+### Pipe multiple processes
+
+```js
+import {execa} from 'execa';
+
+// Similar to `echo unicorns | cat` in Bash
+const {stdout} = await execa('echo', ['unicorns']).pipeStdout(execa('cat'));
+console.log(stdout);
+//=> 'unicorns'
 ```
 
 ### Handling Errors
@@ -236,6 +294,29 @@ This is `undefined` if either:
   - the [`all` option](#all-2) is `false` (the default value)
   - both [`stdout`](#stdout-1) and [`stderr`](#stderr-1) options are set to [`'inherit'`, `'ipc'`, `Stream` or `integer`](https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio)
 
+#### pipeStdout(target)
+
+[Pipe](https://nodejs.org/api/stream.html#readablepipedestination-options) the child process's `stdout` to `target`, which can be:
+  - Another [`execa()` return value](#pipe-multiple-processes)
+  - A [writable stream](#save-and-pipe-output-from-a-child-process)
+  - A [file path string](#redirect-output-to-a-file)
+
+If the `target` is another [`execa()` return value](#execacommandcommand-options), it is returned. Otherwise, the original `execa()` return value is returned. This allows chaining `pipeStdout()` then `await`ing the [final result](#childprocessresult).
+
+The [`stdout` option](#stdout-1) must be kept as `pipe`, its default value.
+
+#### pipeStderr(target)
+
+Like [`pipeStdout()`](#pipestdouttarget) but piping the child process's `stderr` instead.
+
+The [`stderr` option](#stderr-1) must be kept as `pipe`, its default value.
+
+#### pipeAll(target)
+
+Combines both [`pipeStdout()`](#pipestdouttarget) and [`pipeStderr()`](#pipestderrtarget).
+
+Either the [`stdout` option](#stdout-1) or the [`stderr` option](#stderr-1) must be kept as `pipe`, their default value. Also, the [`all` option](#all-2) must be set to `true`.
+
 ### execaSync(file, arguments?, options?)
 
 Execute a file synchronously.
@@ -250,8 +331,12 @@ It's important to note that quotes, backslashes, and spaces are automatically es
 
 The [`shell` option](#shell) must be used if the `command` uses shell-specific features (for example, `&&` or `||`), as opposed to being a simple `file` followed by its `arguments`.
 
+As a convenience, the result from previous [`` $`command` ``](#command) or [`` $.sync`command` ``](#synccommand) calls can be used as template expressions in subsequent commands and `$`/`$.sync` will use the `stdout` value. See the example above [with results from `$` or `$.sync`](#with-results-from--or-sync) for more details.
+
 Returns a `Promise` that resolves or rejects with a [`childProcessResult`](#childProcessResult).
 
+For more information, please see [this page](docs/scripts.md).
+
 ### $.sync\`command\`
 
 Same as [$\`command\`](#command) but synchronous like [`execaSync()`](#execasyncfile-arguments-options).
@@ -639,6 +724,15 @@ Default: `true`
 
 On Windows, do not create a new console window. Please note this also prevents `CTRL-C` [from working](https://github.com/nodejs/node/issues/29837) on Windows.
 
+#### verbose
+
+Type: `boolean`\
+Default: `false`
+
+[Print each command](#verbose-mode) on `stderr` before executing it.
+
+This can also be enabled by setting the `NODE_DEBUG=execa` environment variable in the current process.
+
 #### nodePath *(For `.node()` only)*
 
 Type: `string`\
@@ -670,29 +764,6 @@ const run = async () => {
 console.log(await pRetry(run, {retries: 5}));
 ```
 
-### Save and pipe output from a child process
-
-Let's say you want to show the output of a child process in real-time while also saving it to a variable.
-
-```js
-import {execa} from 'execa';
-
-const subprocess = execa('echo', ['foo']);
-subprocess.stdout.pipe(process.stdout);
-
-const {stdout} = await subprocess;
-console.log('child output:', stdout);
-```
-
-### Redirect output to a file
-
-```js
-import {execa} from 'execa';
-
-const subprocess = execa('echo', ['foo'])
-subprocess.stdout.pipe(fs.createWriteStream('stdout.txt'))
-```
-
 ### Redirect input from a file
 
 ```js


Test patch:
diff --git a/index.test-d.ts b/index.test-d.ts
index 32fb555..1520899 100644
--- a/index.test-d.ts
+++ b/index.test-d.ts
@@ -4,7 +4,8 @@ import {Buffer} from 'node:buffer';
 // to get treated as `any` by `@typescript-eslint/no-unsafe-assignment`.
 import * as process from 'node:process';
 import {type Readable as ReadableStream} from 'node:stream';
-import {expectType, expectError} from 'tsd';
+import {createWriteStream} from 'node:fs';
+import {expectType, expectError, expectAssignable} from 'tsd';
 import {
 	$,
 	execa,
@@ -24,6 +25,39 @@ try {
 	execaPromise.cancel();
 	expectType<ReadableStream | undefined>(execaPromise.all);
 
+	const execaBufferPromise = execa('unicorns', {encoding: null});
+	const writeStream = createWriteStream('output.txt');
+
+	expectAssignable<Function | undefined>(execaPromise.pipeStdout);
+	expectType<ExecaChildProcess>(execaPromise.pipeStdout!('file.txt'));
+	expectType<ExecaChildProcess<Buffer>>(execaBufferPromise.pipeStdout!('file.txt'));
+	expectType<ExecaChildProcess>(execaPromise.pipeStdout!(writeStream));
+	expectType<ExecaChildProcess<Buffer>>(execaBufferPromise.pipeStdout!(writeStream));
+	expectType<ExecaChildProcess>(execaPromise.pipeStdout!(execaPromise));
+	expectType<ExecaChildProcess<Buffer>>(execaPromise.pipeStdout!(execaBufferPromise));
+	expectType<ExecaChildProcess>(execaBufferPromise.pipeStdout!(execaPromise));
+	expectType<ExecaChildProcess<Buffer>>(execaBufferPromise.pipeStdout!(execaBufferPromise));
+
+	expectAssignable<Function | undefined>(execaPromise.pipeStderr);
+	expectType<ExecaChildProcess>(execaPromise.pipeStderr!('file.txt'));
+	expectType<ExecaChildProcess<Buffer>>(execaBufferPromise.pipeStderr!('file.txt'));
+	expectType<ExecaChildProcess>(execaPromise.pipeStderr!(writeStream));
+	expectType<ExecaChildProcess<Buffer>>(execaBufferPromise.pipeStderr!(writeStream));
+	expectType<ExecaChildProcess>(execaPromise.pipeStderr!(execaPromise));
+	expectType<ExecaChildProcess<Buffer>>(execaPromise.pipeStderr!(execaBufferPromise));
+	expectType<ExecaChildProcess>(execaBufferPromise.pipeStderr!(execaPromise));
+	expectType<ExecaChildProcess<Buffer>>(execaBufferPromise.pipeStderr!(execaBufferPromise));
+
+	expectAssignable<Function | undefined>(execaPromise.pipeAll);
+	expectType<ExecaChildProcess>(execaPromise.pipeAll!('file.txt'));
+	expectType<ExecaChildProcess<Buffer>>(execaBufferPromise.pipeAll!('file.txt'));
+	expectType<ExecaChildProcess>(execaPromise.pipeAll!(writeStream));
+	expectType<ExecaChildProcess<Buffer>>(execaBufferPromise.pipeAll!(writeStream));
+	expectType<ExecaChildProcess>(execaPromise.pipeAll!(execaPromise));
+	expectType<ExecaChildProcess<Buffer>>(execaPromise.pipeAll!(execaBufferPromise));
+	expectType<ExecaChildProcess>(execaBufferPromise.pipeAll!(execaPromise));
+	expectType<ExecaChildProcess<Buffer>>(execaBufferPromise.pipeAll!(execaBufferPromise));
+
 	const unicornsResult = await execaPromise;
 	expectType<string>(unicornsResult.command);
 	expectType<string>(unicornsResult.escapedCommand);
@@ -63,6 +97,9 @@ try {
 	expectType<string>(unicornsResult.stdout);
 	expectType<string>(unicornsResult.stderr);
 	expectError(unicornsResult.all);
+	expectError(unicornsResult.pipeStdout);
+	expectError(unicornsResult.pipeStderr);
+	expectError(unicornsResult.pipeAll);
 	expectType<boolean>(unicornsResult.failed);
 	expectType<boolean>(unicornsResult.timedOut);
 	expectError(unicornsResult.isCanceled);
@@ -150,6 +187,7 @@ execa('unicorns', {killSignal: 9});
 execa('unicorns', {signal: new AbortController().signal});
 execa('unicorns', {windowsVerbatimArguments: true});
 execa('unicorns', {windowsHide: false});
+execa('unicorns', {verbose: false});
 /* eslint-enable @typescript-eslint/no-floating-promises */
 execa('unicorns').kill();
 execa('unicorns').kill('SIGKILL');
@@ -265,6 +303,12 @@ expectType<ExecaReturnValue>(await $`unicorns ${['foo', 'bar']}`);
 expectType<ExecaSyncReturnValue>($.sync`unicorns ${['foo', 'bar']}`);
 expectType<ExecaReturnValue>(await $`unicorns ${[1, 2]}`);
 expectType<ExecaSyncReturnValue>($.sync`unicorns ${[1, 2]}`);
+expectType<ExecaReturnValue>(await $`unicorns ${await $`echo foo`}`);
+expectError<ExecaReturnValue>(await $`unicorns ${$`echo foo`}`);
+expectType<ExecaSyncReturnValue>($.sync`unicorns ${$.sync`echo foo`}`);
+expectType<ExecaReturnValue>(await $`unicorns ${[await $`echo foo`, 'bar']}`);
+expectError<ExecaReturnValue>(await $`unicorns ${[$`echo foo`, 'bar']}`);
+expectType<ExecaSyncReturnValue>($.sync`unicorns ${[$.sync`echo foo`, 'bar']}`);
 expectType<ExecaReturnValue>(await $`unicorns ${true.toString()}`);
 expectType<ExecaSyncReturnValue>($.sync`unicorns ${false.toString()}`);
 expectError<ExecaReturnValue>(await $`unicorns ${true}`);
diff --git a/test/command.js b/test/command.js
index 1659306..43b7495 100644
--- a/test/command.js
+++ b/test/command.js
@@ -1,3 +1,4 @@
+import {inspect} from 'node:util';
 import test from 'ava';
 import {execa, execaSync, execaCommand, execaCommandSync, $} from '../index.js';
 import {setFixtureDir} from './helpers/fixtures-dir.js';
@@ -101,11 +102,40 @@ test('$ allows string interpolation', async t => {
 	t.is(stdout, 'foo\nbar');
 });
 
+test('$ allows number interpolation', async t => {
+	const {stdout} = await $`node test/fixtures/echo.js 1 ${2}`;
+	t.is(stdout, '1\n2');
+});
+
 test('$ allows array interpolation', async t => {
 	const {stdout} = await $`node test/fixtures/echo.js ${['foo', 'bar']}`;
 	t.is(stdout, 'foo\nbar');
 });
 
+test('$ allows execa return value interpolation', async t => {
+	const foo = await $`node test/fixtures/echo.js foo`;
+	const {stdout} = await $`node test/fixtures/echo.js ${foo} bar`;
+	t.is(stdout, 'foo\nbar');
+});
+
+test('$ allows execa return value array interpolation', async t => {
+	const foo = await $`node test/fixtures/echo.js foo`;
+	const {stdout} = await $`node test/fixtures/echo.js ${[foo, 'bar']}`;
+	t.is(stdout, 'foo\nbar');
+});
+
+test('$ allows execa return value buffer interpolation', async t => {
+	const foo = await $({encoding: null})`node test/fixtures/echo.js foo`;
+	const {stdout} = await $`node test/fixtures/echo.js ${foo} bar`;
+	t.is(stdout, 'foo\nbar');
+});
+
+test('$ allows execa return value buffer array interpolation', async t => {
+	const foo = await $({encoding: null})`node test/fixtures/echo.js foo`;
+	const {stdout} = await $`node test/fixtures/echo.js ${[foo, 'bar']}`;
+	t.is(stdout, 'foo\nbar');
+});
+
 test('$ ignores consecutive spaces', async t => {
 	const {stdout} = await $`node test/fixtures/echo.js foo    bar`;
 	t.is(stdout, 'foo\nbar');
@@ -141,11 +171,6 @@ test('$ handles invalid escape sequence', async t => {
 	t.is(stdout, '\\u');
 });
 
-test('$ coerces all interpolated expressions to strings', async t => {
-	const {stdout} = await $`node test/fixtures/echo.js ${0} ${undefined} ${null}`;
-	t.is(stdout, '0\nundefined\nnull');
-});
-
 test('$ allows escaping spaces in commands with interpolation', async t => {
 	const {stdout} = await $`${'command with space.js'} foo bar`;
 	t.is(stdout, 'foo\nbar');
@@ -170,3 +195,77 @@ test('$.sync accepts options', t => {
 	const {stdout} = $({stripFinalNewline: true}).sync`noop.js foo`;
 	t.is(stdout, 'foo');
 });
+
+test('$.sync allows execa return value interpolation', t => {
+	const foo = $.sync`node test/fixtures/echo.js foo`;
+	const {stdout} = $.sync`node test/fixtures/echo.js ${foo} bar`;
+	t.is(stdout, 'foo\nbar');
+});
+
+test('$.sync allows execa return value array interpolation', t => {
+	const foo = $.sync`node test/fixtures/echo.js foo`;
+	const {stdout} = $.sync`node test/fixtures/echo.js ${[foo, 'bar']}`;
+	t.is(stdout, 'foo\nbar');
+});
+
+test('$.sync allows execa return value buffer interpolation', t => {
+	const foo = $({encoding: null}).sync`node test/fixtures/echo.js foo`;
+	const {stdout} = $.sync`node test/fixtures/echo.js ${foo} bar`;
+	t.is(stdout, 'foo\nbar');
+});
+
+test('$.sync allows execa return value buffer array interpolation', t => {
+	const foo = $({encoding: null}).sync`node test/fixtures/echo.js foo`;
+	const {stdout} = $.sync`node test/fixtures/echo.js ${[foo, 'bar']}`;
+	t.is(stdout, 'foo\nbar');
+});
+
+const invalidExpression = test.macro({
+	async exec(t, input, expected) {
+		await t.throwsAsync(
+			async () => $`node test/fixtures/echo.js ${input}`,
+			{instanceOf: TypeError, message: expected},
+		);
+
+		t.throws(
+			() => $.sync`node test/fixtures/echo.js ${input}`,
+			{instanceOf: TypeError, message: expected},
+		);
+	},
+	title(prettyInput, input, expected) {
+		return `$ APIs throw on invalid '${prettyInput ?? inspect(input)}' expression with '${expected}'`;
+	},
+});
+
+test(invalidExpression, undefined, 'Unexpected "undefined" in template expression');
+test(invalidExpression, [undefined], 'Unexpected "undefined" in template expression');
+
+test(invalidExpression, null, 'Unexpected "object" in template expression');
+test(invalidExpression, [null], 'Unexpected "object" in template expression');
+
+test(invalidExpression, true, 'Unexpected "boolean" in template expression');
+test(invalidExpression, [true], 'Unexpected "boolean" in template expression');
+
+test(invalidExpression, {}, 'Unexpected "object" in template expression');
+test(invalidExpression, [{}], 'Unexpected "object" in template expression');
+
+test(invalidExpression, {foo: 'bar'}, 'Unexpected "object" in template expression');
+test(invalidExpression, [{foo: 'bar'}], 'Unexpected "object" in template expression');
+
+test(invalidExpression, {stdout: undefined}, 'Unexpected "undefined" stdout in template expression');
+test(invalidExpression, [{stdout: undefined}], 'Unexpected "undefined" stdout in template expression');
+
+test(invalidExpression, {stdout: 1}, 'Unexpected "number" stdout in template expression');
+test(invalidExpression, [{stdout: 1}], 'Unexpected "number" stdout in template expression');
+
+test(invalidExpression, Promise.resolve(), 'Unexpected "object" in template expression');
+test(invalidExpression, [Promise.resolve()], 'Unexpected "object" in template expression');
+
+test(invalidExpression, Promise.resolve({stdout: 'foo'}), 'Unexpected "object" in template expression');
+test(invalidExpression, [Promise.resolve({stdout: 'foo'})], 'Unexpected "object" in template expression');
+
+test('$`noop.js`', invalidExpression, $`noop.js`, 'Unexpected "object" in template expression');
+test('[ $`noop.js` ]', invalidExpression, [$`noop.js`], 'Unexpected "object" in template expression');
+
+test('$({stdio: \'inherit\'}).sync`noop.js`', invalidExpression, $({stdio: 'inherit'}).sync`noop.js`, 'Unexpected "undefined" stdout in template expression');
+test('[ $({stdio: \'inherit\'}).sync`noop.js` ]', invalidExpression, [$({stdio: 'inherit'}).sync`noop.js`], 'Unexpected "undefined" stdout in template expression');
diff --git a/test/fixtures/nested.js b/test/fixtures/nested.js
new file mode 100755
index 0000000..5e329af
--- /dev/null
+++ b/test/fixtures/nested.js
@@ -0,0 +1,7 @@
+#!/usr/bin/env node
+import process from 'node:process';
+import {execa} from '../../index.js';
+
+const [options, file, ...args] = process.argv.slice(2);
+const nestedOptions = {stdio: 'inherit', ...JSON.parse(options)};
+await execa(file, args, nestedOptions);
diff --git a/test/fixtures/verbose-script.js b/test/fixtures/verbose-script.js
new file mode 100755
index 0000000..c242074
--- /dev/null
+++ b/test/fixtures/verbose-script.js
@@ -0,0 +1,6 @@
+#!/usr/bin/env node
+import {$} from '../../index.js';
+
+const $$ = $({stdio: 'inherit'});
+await $$`node -p "one"`;
+await $$`node -p "two"`;
diff --git a/test/pipe.js b/test/pipe.js
new file mode 100644
index 0000000..b465440
--- /dev/null
+++ b/test/pipe.js
@@ -0,0 +1,78 @@
+import {PassThrough, Readable} from 'node:stream';
+import {spawn} from 'node:child_process';
+import {readFile} from 'node:fs/promises';
+import tempfile from 'tempfile';
+import test from 'ava';
+import getStream from 'get-stream';
+import {execa} from '../index.js';
+import {setFixtureDir} from './helpers/fixtures-dir.js';
+
+setFixtureDir();
+
+const pipeToProcess = async (t, fixtureName, funcName) => {
+	const {stdout} = await execa(fixtureName, ['test'], {all: true})[funcName](execa('stdin.js'));
+	t.is(stdout, 'test');
+};
+
+test('pipeStdout() can pipe to Execa child processes', pipeToProcess, 'noop.js', 'pipeStdout');
+test('pipeStderr() can pipe to Execa child processes', pipeToProcess, 'noop-err.js', 'pipeStderr');
+test('pipeAll() can pipe stdout to Execa child processes', pipeToProcess, 'noop.js', 'pipeAll');
+test('pipeAll() can pipe stderr to Execa child processes', pipeToProcess, 'noop-err.js', 'pipeAll');
+
+const pipeToStream = async (t, fixtureName, funcName, streamName) => {
+	const stream = new PassThrough();
+	const result = await execa(fixtureName, ['test'], {all: true})[funcName](stream);
+	t.is(result[streamName], 'test');
+	t.is(await getStream(stream), 'test\n');
+};
+
+test('pipeStdout() can pipe to streams', pipeToStream, 'noop.js', 'pipeStdout', 'stdout');
+test('pipeStderr() can pipe to streams', pipeToStream, 'noop-err.js', 'pipeStderr', 'stderr');
+test('pipeAll() can pipe stdout to streams', pipeToStream, 'noop.js', 'pipeAll', 'stdout');
+test('pipeAll() can pipe stderr to streams', pipeToStream, 'noop-err.js', 'pipeAll', 'stderr');
+
+const pipeToFile = async (t, fixtureName, funcName, streamName) => {
+	const file = tempfile('.txt');
+	const result = await execa(fixtureName, ['test'], {all: true})[funcName](file);
+	t.is(result[streamName], 'test');
+	t.is(await readFile(file, 'utf8'), 'test\n');
+};
+
+test('pipeStdout() can pipe to files', pipeToFile, 'noop.js', 'pipeStdout', 'stdout');
+test('pipeStderr() can pipe to files', pipeToFile, 'noop-err.js', 'pipeStderr', 'stderr');
+test('pipeAll() can pipe stdout to files', pipeToFile, 'noop.js', 'pipeAll', 'stdout');
+test('pipeAll() can pipe stderr to files', pipeToFile, 'noop-err.js', 'pipeAll', 'stderr');
+
+const invalidTarget = (t, funcName, getTarget) => {
+	t.throws(() => execa('noop.js', {all: true})[funcName](getTarget()), {
+		message: /a stream or an Execa child process/,
+	});
+};
+
+test('pipeStdout() can only pipe to writable streams', invalidTarget, 'pipeStdout', () => new Readable());
+test('pipeStderr() can only pipe to writable streams', invalidTarget, 'pipeStderr', () => new Readable());
+test('pipeAll() can only pipe to writable streams', invalidTarget, 'pipeAll', () => new Readable());
+test('pipeStdout() cannot pipe to non-processes', invalidTarget, 'pipeStdout', () => ({stdin: new PassThrough()}));
+test('pipeStderr() cannot pipe to non-processes', invalidTarget, 'pipeStderr', () => ({stdin: new PassThrough()}));
+test('pipeAll() cannot pipe to non-processes', invalidTarget, 'pipeStderr', () => ({stdin: new PassThrough()}));
+test('pipeStdout() cannot pipe to non-Execa processes', invalidTarget, 'pipeStdout', () => spawn('node', ['--version']));
+test('pipeStderr() cannot pipe to non-Execa processes', invalidTarget, 'pipeStderr', () => spawn('node', ['--version']));
+test('pipeAll() cannot pipe to non-Execa processes', invalidTarget, 'pipeStderr', () => spawn('node', ['--version']));
+
+const invalidSource = (t, funcName) => {
+	t.false(funcName in execa('noop.js', {stdout: 'ignore', stderr: 'ignore'}));
+};
+
+test('Must set "stdout" option to "pipe" to use pipeStdout()', invalidSource, 'pipeStdout');
+test('Must set "stderr" option to "pipe" to use pipeStderr()', invalidSource, 'pipeStderr');
+test('Must set "stdout" or "stderr" option to "pipe" to use pipeAll()', invalidSource, 'pipeAll');
+
+const invalidPipeToProcess = async (t, fixtureName, funcName) => {
+	t.throws(() => execa(fixtureName, ['test'], {all: true})[funcName](execa('stdin.js', {stdin: 'ignore'})), {
+		message: /stdin must be available/,
+	});
+};
+
+test('Must set target "stdin" option to "pipe" to use pipeStdout()', invalidPipeToProcess, 'noop.js', 'pipeStdout');
+test('Must set target "stdin" option to "pipe" to use pipeStderr()', invalidPipeToProcess, 'noop-err.js', 'pipeStderr');
+test('Must set target "stdin" option to "pipe" to use pipeAll()', invalidPipeToProcess, 'noop.js', 'pipeAll');
diff --git a/test/verbose.js b/test/verbose.js
new file mode 100644
index 0000000..fa0a605
--- /dev/null
+++ b/test/verbose.js
@@ -0,0 +1,35 @@
+import test from 'ava';
+import {execa} from '../index.js';
+import {setFixtureDir} from './helpers/fixtures-dir.js';
+
+setFixtureDir();
+
+const normalizeTimestamp = output => output.replace(/\d/g, '0');
+const testTimestamp = '[00:00:00.000]';
+
+test('Prints command when "verbose" is true', async t => {
+	const {stdout, stderr, all} = await execa('nested.js', [JSON.stringify({verbose: true}), 'noop.js', 'test'], {all: true});
+	t.is(stdout, 'test');
+	t.is(normalizeTimestamp(stderr), `${testTimestamp} noop.js test`);
+	t.is(normalizeTimestamp(all), `${testTimestamp} noop.js test\ntest`);
+});
+
+test('Prints command with NODE_DEBUG=execa', async t => {
+	const {stdout, stderr, all} = await execa('nested.js', [JSON.stringify({}), 'noop.js', 'test'], {all: true, env: {NODE_DEBUG: 'execa'}});
+	t.is(stdout, 'test');
+	t.is(normalizeTimestamp(stderr), `${testTimestamp} noop.js test`);
+	t.is(normalizeTimestamp(all), `${testTimestamp} noop.js test\ntest`);
+});
+
+test('Escape verbose command', async t => {
+	const {stderr} = await execa('nested.js', [JSON.stringify({verbose: true}), 'noop.js', 'one two', '"'], {all: true});
+	t.true(stderr.endsWith('"one two" "\\""'));
+});
+
+test('Verbose option works with inherit', async t => {
+	const {all} = await execa('verbose-script.js', {all: true, env: {NODE_DEBUG: 'execa'}});
+	t.is(normalizeTimestamp(all), `${testTimestamp} node -p "\\"one\\""
+one
+${testTimestamp} node -p "\\"two\\""
+two`);
+});