Reading and Writing from the Terminal in a Shell Pipeline

Last updated 07 November 2021

When writing a shell script that might be used in a pipeline, it can be useful to bypass the rerouting of stdin and stdout to still interact directly with the user on the terminal. Let’s take a simple script that lets you pick one of the lines from stdin by number and then prints it to stdout.

Our first, not working, attempt looks like this:

#!/bin/sh
# select.sh

while read -r line; do
  if [ -z "$lines" ]; then
    lines="$line"
  else
    lines="$lines\n$line"
  fi
  echo "$line"
done
printf "Line number> "
read -r selection
line_num=0
echo $lines | while read -r line; do
  line_num=$(( line_num + 1 ))
  if [ "$line_num" -eq "$selection" ]; then
    echo "$line"
  fi
done

If you try and run this script you’ll notice you’re never prompted for input.

$ echo '1 2 3
> 4 5 6
> 7 8 9' | sh select.sh
Line number> select.sh: 17: [: Illegal number:
# more output

The trick is to explicitly read input from /dev/tty by redirecting stdin when doing read:

#!/bin/sh
# select.sh

while read -r line; do
  if [ -z "$lines" ]; then
    lines="$line"
  else
    lines="$lines\n$line"
  fi
  echo "$line"
done
printf "Line number> "
read -r selection </dev/tty
line_num=0
echo $lines | while read -r line; do
  line_num=$(( line_num + 1 ))
  if [ "$line_num" -eq "$selection" ]; then
    echo "$line"
  fi
done

This works great now:

$ echo '1 2 3
4 5 6
7 8 9' | sh select.sh
1 2 3
4 5 6
7 8 9
Line number> 2
4 5 6

But that isn’t actually that useful. You probably want to pipe that into another command:

$ echo '1 2 3
4 5 6
7 8 9' | sh select.sh | wc

When you run it, though, it just hangs with no output. That’s because all the echos are now going to the pipe instead of to the terminal! You can use stderr (aka file descriptor 2) for this:

#!/bin/sh
# select.sh

while read -r line; do
  if [ -z "$lines" ]; then
    lines="$line"
  else
    lines="$lines\n$line"
  fi
  echo "$line"
done >&2
printf "Line number> " >&2
read -r selection </dev/tty
line_num=0
echo $lines | while read -r line; do
  line_num=$(( line_num + 1 ))
  if [ "$line_num" -eq "$selection" ]; then
    echo "$line"
  fi
done

Now when you run it, it behaves as expected:

$ echo '1 2 3
4 5 6
7 8 9' | sh select.sh | wc
1 2 3
4 5 6
7 8 9
Line number> 2
      1       3       6

Hopefully you’ll find this helpful when you do some shell scripting.