Asking a Yes/No Question from a Bash Script
In order to avoid this common mistake I often have my shell scripts prompt me for a yes or no answer before they proceed. The function described here is for doing that: asking a question and validating the answer.
The function is pretty simple it accepts a couple of options and the remainder of the arguments are presumed to be the question text. It prompts the user for an answer and validates that the answer is one of "yes", "y", "no", or "n". The answer is converted to lower case so any combination of case is accepted.
The available options are --timeout N which causes the prompt to timeout after N seconds, and --default ANS which provides a default answer for prompts that timeout. It also allows the user to hit ENTER and accept the default answer. The function code follows:
#!/bin/bash.sh
#
#####################################################################
# Print warning message.
function warning()
{
echo "$*" >&2
}
#####################################################################
# Print error message and exit.
function error()
{
echo "$*" >&2
exit 1
}
#####################################################################
# Ask yesno question.
#
# Usage: yesno OPTIONS QUESTION
#
# Options:
# --timeout N Timeout if no input seen in N seconds.
# --default ANS Use ANS as the default answer on timeout or
# if an empty answer is provided.
#
# Exit status is the answer.
function yesno()
{
local ans
local ok=0
local timeout=0
local default
local t
while [[ "$1" ]]
do
case "$1" in
--default)
shift
default=$1
if [[ ! "$default" ]]; then error "Missing default value"; fi
t=$(tr '[:upper:]' '[:lower:]' <<<$default)
if [[ "$t" != 'y' && "$t" != 'yes' && "$t" != 'n' && "$t" != 'no' ]]; then
error "Illegal default answer: $default"
fi
default=$t
shift
;;
--timeout)
shift
timeout=$1
if [[ ! "$timeout" ]]; then error "Missing timeout value"; fi
if [[ ! "$timeout" =~ ^[0-9][0-9]*$ ]]; then error "Illegal timeout value: $timeout"; fi
shift
;;
-*)
error "Unrecognized option: $1"
;;
*)
break
;;
esac
done
if [[ $timeout -ne 0 && ! "$default" ]]; then
error "Non-zero timeout requires a default answer"
fi
if [[ ! "$*" ]]; then error "Missing question"; fi
while [[ $ok -eq 0 ]]
do
if [[ $timeout -ne 0 ]]; then
if ! read -t $timeout -p "$*" ans; then
ans=$default
else
# Turn off timeout if answer entered.
timeout=0
if [[ ! "$ans" ]]; then ans=$default; fi
fi
else
read -p "$*" ans
if [[ ! "$ans" ]]; then
ans=$default
else
ans=$(tr '[:upper:]' '[:lower:]' <<<$ans)
fi
fi
if [[ "$ans" == 'y' || "$ans" == 'yes' || "$ans" == 'n' || "$ans" == 'no' ]]; then
ok=1
fi
if [[ $ok -eq 0 ]]; then warning "Valid answers are: yes y no n"; fi
done
[[ "$ans" = "y" || "$ans" == "yes" ]]
}
if [[ $(basename "$0" .sh) == 'yesno' ]]; then
if yesno "Test bad timeout value? "; then
yesno --timeout none "Hello? "
fi
if yesno "Test timeout without default value? "; then
yesno --timeout 10 "Hello? "
fi
if yesno "Test bad default value? "; then
yesno --default none "Hello? "
fi
if yesno "Yes or no? "; then
echo "You answered yes"
else
echo "You answered no"
fi
if yesno --default yes "Yes or no (default yes) ? "; then
echo "You answered yes"
else
echo "You answered no"
fi
if yesno --default no "Yes or no (default no) ? "; then
echo "You answered yes"
else
echo "You answered no"
fi
if yesno --timeout 5 --default no "Yes or no (timeout 5, default no) ? "; then
echo "You answered yes"
else
echo "You answered no"
fi
if yesno --timeout 5 --default yes "Yes or no (timeout 5, default yes) ? "; then
echo "You answered yes"
else
echo "You answered no"
fi
fi
# vim: tabstop=4: shiftwidth=4: noexpandtab:
# kate: tab-width 4; indent-width 4; replace-tabs false;
The code starts with a couple of functions which print warning and error messages. The main function checks the arguments then loops until it receives a valid answer. Note that if a timeout was specified and any answer (valid or invalid) is entered the timeout is turned off. The last line of the function tests the answer to see if it's value is "yes" or "y", thereby setting the exit status of the function.
The code at the end of the file is only executed if you invoke the file directly rather than sourceing it into your shell script. Output from a direct run follows:
$ sh yesno.sh
Test bad timeout value? n
Test timeout without default value? n
Test bad default value? n
Yes or no? yep
Valid answers are: yes y no n
Yes or no? yes
You answered yes
Yes or no (default yes) ? <ENTER>
You answered yes
Yes or no (default no) ? <ENTER>
You answered no
Yes or no (timeout 5, default no) ? You answered no
Yes or no (timeout 5, default yes) ? You answered yes
Notice the last couple of lines, no answer was provided so the default was used after the timeout. That's why the response text appears on the same line as the question.
| Attachment | Size |
|---|---|
| shooting-yourself-in-the-foot.jpg | 19.86 KB |
Mitch Frazier is an Associate Editor for Linux Journal and the Web Editor for linuxjournal.com.










This week 5 lucky Members will receive a copy of The Official Ubuntu Server Book by Benjamin Mako Hill and Linux Journal's very own Kyle Rankin. No entry necessary. Check back here early next week to find out who the lucky Online Members are.




Comments
holy cow
I just read all of this. I am so new to bash, I don't even know what "shift" means, or why you use [[]] double brackets. I really appreciate that you wrote the article with noobs like me in mind, just reading it was fun, even if I barely understand it, I can copy/paste and use it. As for the comments, this is why Linux is great. we all get to have our very own way of doing it.
Thank you
I'd like to offer some humble improvements.
I have to warn you, I'm really not very much fun at parties, simply because I take my shell scripting seriously.
There's a bunch of bad stuff in your code so please don't take my criticism the wrong way. I am trying to be constructive.
#!/bin/bash # First thing to note is that this is a bash script. Your script seemed to be running something called bash.sh # I have no idea what that was. # Set up warning and error. Why NOT make error call warning. Also note that there is never a good reason to refer # to $*. Instead, always use "$@". And the cost savings of using >&2 instead of 1>&2 is just not worth it. warning() { echo "$@" 1>&2; } # Also, let's not forget that simple commands can be stacked into one command line. error() { warning "$@"; exit 1; } # Here we set usage up so that we can die with a bit more elegance. usage() { warning "$@" cat <&2 Usage: $prog [--timeout val] [--default yes|no] prompt EOF exit 1 } # # Usage: yesno OPTIONS QUESTION # # Options: # --timeout N Timeout if no input seen in N seconds. # --default ANS Use ANS as the default answer on timeout or # if an empty answer is provided. # # Exit status is the answer. # One notion is to declare things to be of type int. # Also, never do your own options parsing. It's really hard and why invent the wheel? yesno() { local ans local -i ok=0 local -i timeout=0 local tv # TimeoutVal local default local temp # BTW, NEVER give a variabel a one-letter name. It's hard to find them using any editor. temp=$(getopt -o :d:t:h --long default:,timeout:,help -n $prog -- "$@") eval set -- "$temp" # The double quotes are key here. shopt -s nocasematch # Hey, this is bash. Let's not make life harder for ourselves. # None of this checking to see if it's equal to y or to Y. while true do case "$1" in -d | --default) # No need to shift 1 to then get the value and then shift again. Just ref "$2" and then shift 2. default="$2" [[ -z "$default" ]] && error 'Missing default value' [[ "$default" != y && "$default" != yes && "$default" != n && "$default" != no ]] && error "Illegal default answer: $default" shift 2 ;; -t | --timeout) # Note that tv is a string but if we decide that it's safe to assign to an in var then we do it. tv="$2" [[ -z "$tv" ]] && error 'Missing timeout value' [[ "$tv" =~ ^[0-9][0-9]*$ ]] && timeout=$((tv)) || error "Illegal timeout value: $tv" shift 2 ;; -h | --help) usage "Here's what you need to do to run this" ;; --) This is how e get out of the options processing game. shift break ;; *) usage "$1: Unknown option" ;; esac done # Now that we're done with the procopt then we can do the consistency checks. if (( timeout != 0 )) && [[ -z "$default" ]] then error 'Non-zero timeout requires a default answer' fi (( $# == 0 )) && error 'Missing question' while (( ok == 0 )) do if (( timeout != 0 )) then if read -t $timeout -p "$@" ans then # Turn off timeout if answer entered. timeout=0 # No need for all this testing and then assigning stuff. Just do it. : ${ans:="$default"} else ans=$default fi else read -p "$@" ans : ${ans:=$default} fi [[ "$ans" = y || "$ans" = yes || "$ans" = n || "$ans" = no ]] && ok=1 || warning 'Valid answers are: yes y no n' done [[ "$ans" = y || "$ans" = yes ]] } prog=$0 # This is just so you can see that you don't need basename and dirname. This is bash, not sh. fn=${0##*/} noshext=${fn%.sh} # Also, not that I'm testing yesno, not 'yesno' and not "yesno". Don't interpolate if you don't have to.. if [[ ${noshext} == yesno ]] then yesno 'Test bad timeout value? ' && yesno --timeout none "Hello? " yesno 'Test timeout without default value? ' && yesno --timeout 10 'Hello? ' yesno 'Test bad default value? ' && yesno --default none "Hello? " yesno 'Yes or no? ' && echo 'You answered yes' || echo 'You answered no' yesno --default yes 'Yes or no (default yes) ? ' && echo 'You answered yes' || echo "You answered no" yesno --default no 'Yes or no (default no) ? ' && echo 'You answered yes' || echo 'You answered no' yesno --timeout 5 --default no 'Yes or no (timeout 5, default no) ? ' && echo 'You answered yes' || echo 'You answered no' yesno --timeout 5 --default yes 'Yes or no (timeout 5, default yes) ? ' && echo 'You answered yes' || echo 'You answered no' fiSo, in the end, the goal is to write bash code and not Bourne shell code. Make it simple to read.
Some Useful Improvements
There are some useful improvements in your code: the usage function, the single character flags, and compressing the test code so it's less obtrusive, I like that. And yeah, should be bash, not bash.sh in the first line. However, the rest of your changes and the "programming" recommendations in your comments, to me, are just style differences and they don't make your stuff "good" and mine "bad" as you say, they just make them different.
So, in the end, the goal is to write something that does what you want.
p.s. I suspect you're right, you're probably not much fun at parties.
Mitch Frazier is an Associate Editor for Linux Journal and the Web Editor for linuxjournal.com.
What common mistake??
What common mistake does this script help to avoid? Your 'this common mistake' link is just a picture to a gun pointing at a foot.
Of Shooting Myself in the Foot
The mistake of running something potentially "dangerous" without verifying that I really want to do it and thereby shooting myself in the foot.
Mitch Frazier is an Associate Editor for Linux Journal and the Web Editor for linuxjournal.com.
Poor - horrendously complicated and unnecessary
That code is horrendously complicated for something that should be simple. For example, why test for 'yes' and 'no' when you could force a one-character user input by passing '-n 1' to the read command? Secondly, use getopt or getopts to parse arguments - gives more flexibility. Also why allow the user to make mistakes by allowing them to specifiy arbitrary defaults? Specifying the default by passing '-y' or '-n' means it's easier to use and easier to write (no need to check for invalid defaults - handled automatically by getopt).
Thanks For Remaining Calm
Why not use "-n 1"? Well because it returns as soon as you type a character, so you don't get an opportunity to rethink your answer. Also, if you type more than one character in reply to the prompt, the subsequent characters get used by the next read command. Neither of those is desirable in my mind.
I don't usually use getopt/getopts because I find the resulting code less clear and often just as long as hand written code.
The -y/-n is a good idea.
Mitch Frazier is an Associate Editor for Linux Journal and the Web Editor for linuxjournal.com.
Nice.
Nice, but bash is so confusing. I'll stick to python.
really...
Why often this judgemental tone? "I'm better than you , and anonymous as well" .
Well, live and let live. Be positive and help people grow, much better. I'm new at this (bash scripts) and it's better to have almost perfect info that I understand than "Wow look at me" info that I don't understand.
overly verbose test
The following:
if [[ "$t" != 'y' && "$t" != 'yes' && "$t" != 'n' && "$t" != 'no' ]]; then
error "Illegal default answer: $default"
fi
Can be more clearly put as:
case "${t}" in
y)
yes)
n)
no) ;;# acceptable
*)
error "Illegal default answer: $default
esac
Even Shorter
An even more compact version of your version would be:
case "${t}" in y|yes|n|no) ;; # acceptable *) error "Illegal default answer: $default";; esacSince your version contains fewer characters I guess that makes it less verbose, but personally I also find it to be less clear.
Mitch Frazier is an Associate Editor for Linux Journal and the Web Editor for linuxjournal.com.
Your use of $* over $@ shows
Your use of $* over $@ shows that you have still many bullets left.
Your use of the first few words of your comment as the
Your use of the first few words of your comment as the Subject of your comments shows a lack of imagination and a poor understanding of the purpose of the Subject field.
Yes Grasshopper
Many bullets...
Mitch Frazier is an Associate Editor for Linux Journal and the Web Editor for linuxjournal.com.
I prefer a simpler solution...
until [ $happy = "Yes"]; do
echo "do you like pie? Your stuck in here until then."
select happy in Yes No
do
break
done
Gives you a simple "press 1 for yes 2 for no" menu to use; fudge for you own use of course.
I like this!!
I like this simpler solution as well! :-)
Post new comment