Useless shell commands

Fixing echo

When there’s an echo, you only hear the last few syllables of the sound. This makes the unix echo incredibly confusing! Rather than doing the natural, useful thing and repeating the last “sound” of the previous command, it instead shouts out the arguments that you pass to it. Thankfully, it’s simple to update your shell to fix this problem:

true_echo () {
  history | `# get all previous commands` \
  tail -n 1 | `# choose only the most recent one` \
  awk '{ print $NF }' `# prints last field. $NF = number of fields. awk is 1-indexed, and $0 refers to the full line`
}
shout () {
  # we overrode `echo`, but we can still reference it by using `builtin X` # this technique can be useful if you want to extend a command like `cd` to do extra things!
  builtin echo $@
}

alias echo=true_echo
alias shout=shout
$ echo hello world
world

Writing shell scripts with HEREDOCs and file redirection

One of my favorite parts of working in the shell is the ability to customize it with my own shell scripts by creating a file /usr/local/bin and chmod +x it to make it runnable. One problem I run into pretty often is that I want to write these scripts with Node or Python rather than bash, but HEREDOCs can make it ergonomic to work around that problem:

#!/usr/bin/env bash

script=$(
cat <<'EOF'
const { execSync } = require("child_process");

execSync(`echo "hello, world"`, { stdio: "inherit" });
EOF
)
node -e $script # prints "hello, world"
  • #!/usr/bin/env bash: why would you want #!/usr/bin/env node or #!/usr/bin/env zx?
  • The single quotes around the HEREDOC EOF marker are necessary to avoid the backticks being expanded.
  • You can choose any string you’d like to delimit the start and end of the HEREDOC. EOF or END_OF_FILE are both common choices.

If you’ve forgotten the right option to use with your programming language to provide a string script, the easiest way to work around this—far easier than searching online or using --help—is to use file redirection with a HEREDOC:

node <(cat <<'EOF'
console.log("hello, world");
EOF
)

<() tells the shell to treat the output of your command as if it were a file. file <(echo "hello") will show you a file descriptor like /dev/fd/11: fifo (named pipe). Some files only take a file path as an input, and in those cases it can sometimes be slightly more ergonomic to use <() as an input rather than creating a temporary file.

Using cowsay on stderr

cowsay (brew install cowsay) is a commonly used tool to help process logs, but it’s important to note that it only highlights information from stdout. Many processes will pipe their informational logging to stderr, so if you want to use cowsay with a tool like that, you’ll need to redirect stderr (2) to stdout (1): 2>&1. Example—brew install sl 2>&1 | cowsay.

You might also need to redirect stderr to stdout if you’re doing something like grepping for an error message from a test or script.

Making say behave more like tee

One of the problems I run into most often with say is that it doesn’t behave like tee and continue piping its output after it’s finished reading a line. This keeps me from using it in the middle of pipelines. Thankfully, it’s easy to add a function to your shell that narrates your pipeline. Create a file at /usr/local/bin/say_tee with the following:

#!/usr/bin/env zsh

set -e

# we don't want the same voice every time!
# so we need to persist the voice we used last somewhere
if [[ ! -f /tmp/voice_index ]]; then
  echo -1 > /tmp/voice_index
fi
function choose_voice () {
  local voice_index=$(cat /tmp/voice_index)
  local voice_list=$(say -v'?' | gsed -rn 's|^(.*)\s+en_US.*|\1|p' | gsed -r '/\s+$/d')
  local voice_count=$(echo $voice_list | wc -l)
  voice_index=$(( (voice_index + 1) % voice_count ))
  echo $voice_index > /tmp/voice_index

  # zsh and bash differ in how they set up arrays
  # so I'm choosing the voice in a silly way that should work for both
  # ...and if do you ever need to use arrays in bash, I'm sorry
  echo $voice_list | awk "NR == $(( voice_index + 1))"
}

function say_tee () {
  say -v "$(choose_voice)" "$@"
  echo "$@"
}

say_tee "$@"

Usage will look like tail -n3 /usr/share/dict/words | xargs -n1 say_tee. It will read out “zythum, Zyzomys, Zyzzogeton” and output each word to the command line after it finishes reading it.

  • Creating say_tee as a function in your ~/.zshrc won’t work because xargs won’t be able to exec it! You’ll get an error saying “No such file or directory”
  • /tmp will normally be cleared on reboot, so you’ll need to store your voice_index somewhere else if you want this to persist longer

Are these commands truly useless?

Yes.

But playing around with things is how you learn. I picked up a few small things while messing around for this post:

  • I’d always meant to look into why xargs my_zshrc_defined_function didn’t work. It’s something I’ve run into before, but had never had the chance to look it up.
  • I’d assumed that gsed -r /pattern/d would work similarly to gsed -r s|pattern|replacement| where the delimiter will be any character follows the s. For deletes, you must use /.
  • I’m now marginally more comfortable with HEREDOCs. I won’t ever use a HEREDOC in real code, but I now find them a little easier to read.
  • I tried pasting some of this code into my shell to test it out, and it failed because command not found: #. I hadn’t realized that setopt INTERACTIVE_COMMENTS is needed for comments in an interactive shell. (I haven’t customized my ~/.zshrc on the computer I’m writing this on yet, so I’m re-learning some of my own default settings)

Hopefully you encountered at least one thing you didn’t know!