Writing interactive scripts In this chapter we will discuss how to interact with the users of our scripts: Printing user friendly messages and explanations Catching user input Prompting for user input Using the file descriptors to read from and write to multiple files Displaying user messages Interactive or not? Some scripts run without any interaction from the user at all. Advantages of non-interactive scripts include: The script runs in a predictable way every time. The script can run in the background. Many scripts, however, require input from the user, or give output to the user as the script is running. The advantages of interactive scripts are, among others: More flexible scripts can be built. Users can customize the script as it runs or make it behave in different ways. The script can report its progress as it runs. When writing interactive scripts, never hold back on comments. A script that prints appropriate messages is much more user-friendly and can be more easily debugged. A script might do a perfect job, but you will get a whole lot of support calls if it does not inform the user about what it is doing. So include messages that tell the user to wait for output because a calculation is being done. If possible, try to give an indication of how long the user will have to wait. If the waiting should regularly take a long time when executing a certain task, you might want to consider integrating some processing indication in the output of your script. When prompting the user for input, it is also better to give too much than too little information about the kind of data to be entered. This applies to the checking of arguments and the accompanying usage message as well. Bash has the echo and printf commands to provide comments for users, and although you should be familiar with at least the use of echo by now, we will discuss some more examples in the next sections. Using the echo built-in command The echo built-in command outputs its arguments, separated by spaces and terminated with a newline character. The return status is always zero. echo takes a couple of options: : interprets backslash-escaped characters. : suppresses the trailing newline. As an example of adding comments, we will make the feed.sh and penguin.sh from a bit better: michel ~/test> cat penguin.sh #!/bin/bash # This script lets you present different menus to Tux. He will only be happy # when given a fish. To make it more fun, we added a couple more animals. if [ "$menu" == "fish" ]; then if [ "$animal" == "penguin" ]; then echo -e "Hmmmmmm fish... Tux happy!\n" elif [ "$animal" == "dolphin" ]; then echo -e "\a\a\aPweetpeettreetppeterdepweet!\a\a\a\n" else echo -e "*prrrrrrrt*\n" fi else if [ "$animal" == "penguin" ]; then echo -e "Tux don't like that. Tux wants fish!\n" exit 1 elif [ "$animal" == "dolphin" ]; then echo -e "\a\a\a\a\a\aPweepwishpeeterdepweet!\a\a\a" exit 2 else echo -e "Will you read this sign?! Don't feed the "$animal"s!\n" exit 3 fi fi michel ~/test> cat feed.sh #!/bin/bash # This script acts upon the exit status given by penguin.sh if [ "$#" != "2" ]; then echo -e "Usage of the feed script:\t$0 food-on-menu animal-name\n" exit 1 else export menu="$1" export animal="$2" echo -e "Feeding $menu to $animal...\n" feed="/nethome/anny/testdir/penguin.sh" $feed $menu $animal result="$?" echo -e "Done feeding.\n" case "$result" in 1) echo -e "Guard: \"You'd better give'm a fish, less they get violent...\"\n" ;; 2) echo -e "Guard: \"No wonder they flee our planet...\"\n" ;; 3) echo -e "Guard: \"Buy the food that the Zoo provides at the entry, you ***\"\n" echo -e "Guard: \"You want to poison them, do you?\"\n" ;; *) echo -e "Guard: \"Don't forget the guide!\"\n" ;; esac fi echo "Leaving..." echo -e "\a\a\aThanks for visiting the Zoo, hope to see you again soon!\n" michel ~/test> feed.sh apple camel Feeding apple to camel... Will you read this sign?! Don't feed the camels! Done feeding. Guard: "Buy the food that the Zoo provides at the entry, you ***" Guard: "You want to poison them, do you?" Leaving... Thanks for visiting the Zoo, hope to see you again soon! michel ~/test> feed.sh apple Usage of the feed script: ./feed.sh food-on-menu animal-name More about escape characters can be found in . The following table gives an overview of sequences recognized by the echo command: Escape sequences used by the echo command SequenceMeaning\aAlert (bell).\bBackspace.\cSuppress trailing newline.\eEscape.\fForm feed.\nNewline.\rCarriage return.\tHorizontal tab.\vVertical tab.\\Backslash.\0NNNThe eight-bit character whose value is the octal value NNN (zero to three octal digits).\NNNThe eight-bit character whose value is the octal value NNN (one to three octal digits).\xHHThe eight-bit character whose value is the hexadecimal value (one or two hexadecimal digits).
For more information about the printf command and the way it allows you to format output, see the Bash info pages. Keep in mind that there might be differences between different versions of Bash.
Catching user input Using the read built-in command The read built-in command is the counterpart of the echo and printf commands. The syntax of the read command is as follows: read NAME1 NAME2 ... NAMEN One line is read from the standard input, or from the file descriptor supplied as an argument to the option. The first word of the line is assigned to the first name, NAME1, the second word to the second name, and so on, with leftover words and their intervening separators assigned to the last name, NAMEN. If there are fewer words read from the input stream than there are names, the remaining names are assigned empty values. The characters in the value of the IFS variable are used to split the input line into words or tokens; see . The backslash character may be used to remove any special meaning for the next character read and for line continuation. If no names are supplied, the line read is assigned to the variable REPLY. The return code of the read command is zero, unless an end-of-file character is encountered, if read times out or if an invalid file descriptor is supplied as the argument to the option. The following options are supported by the Bash read built-in: Options to the read built-in OptionMeaning-a ANAMEThe words are assigned to sequential indexes of the array variable ANAME, starting at 0. All elements are removed from ANAME before the assignment. Other NAME arguments are ignored.-d DELIMThe first character of DELIM is used to terminate the input line, rather than newline.-ereadline is used to obtain the line.-n NCHARSread returns after reading NCHARS characters rather than waiting for a complete line of input.-p PROMPTDisplay PROMPT, without a trailing newline, before attempting to read any input. The prompt is displayed only if input is coming from a terminal.-rIf this option is given, backslash does not act as an escape character. The backslash is considered to be part of the line. In particular, a backslash-newline pair may not be used as a line continuation.-sSilent mode. If input is coming from a terminal, characters are not echoed.-t TIMEOUTCause read to time out and return failure if a complete line of input is not read within TIMEOUT seconds. This option has no effect if read is not reading input from the terminal or from a pipe.-u FDRead input from file descriptor FD.
This is a straightforward example, improving on the leaptest.sh script from the previous chapter: michel ~/test> cat leaptest.sh #!/bin/bash # This script will test if you have given a leap year or not. echo "Type the year that you want to check (4 digits), followed by [ENTER]:" read year if (( ("$year" % 400) == "0" )) || (( ("$year" % 4 == "0") && ("$year" % 100 != "0") )); then echo "$year is a leap year." else echo "This is not a leap year." fi michel ~/test> leaptest.sh Type the year that you want to check (4 digits), followed by [ENTER]: 2000 2000 is a leap year.
Prompting for user input The following example shows how you can use prompts to explain what the user should enter. michel ~/test> cat books.sh #!/bin/bash # This is a program that creates a favorite book library. books="books.txt" echo "Hello, "$USER". This script will add your favorite book to the database." echo -n "Enter the title and press [ENTER]: " read title echo -n "Enter the name of the author and press [ENTER]: " read author echo grep -i "$title" "$books" if [ $? == 0 ]; then echo "You have already suggested a book, quitting." exit 1 elif [ "$author" == "shakespeare" ]; then echo "What's in a name? That which we call a rose by any other name would smell as sweet." exit 1 else echo -n "Enter the cost and press [ENTER]: " read cost if [ $cost -lt 25 ]; then echo "$title | $author | $cost" >> "$books" echo "Your book is added to the database. Thank you so much!" else echo "Let me look for $title by $author at the library." exit 1 fi fi michel ~/test> cp books.sh /var/tmp; cd /var/tmp michel ~/test> touch books; chmod books michel ~/test> books.sh Hello, michel. This script will add your favorite book to the database. Enter the title and press [ENTER]: 1984 Enter the name of the author and press [ENTER]: Orwell Enter the cost and press [ENTER]: 30 Let me look 1894 by Orwell at the library. michel ~/test> cat books Note that no output is omitted here. The script only stores information about the books that Michel is interested in. It will always thank you for your suggestion, unless you already provided it. Other people can now start executing the script: [anny@octarine tmp]$ books.sh Hello, anny. This script will add your favorite book to the database. Enter the title and press [ENTER]: Sense and Sensibility Enter the name of the author and press [ENTER]: Austen Enter the cost and press [ENTER]: 10 Your book is added to the database. Thank you so much! After a while, the books list begins to look like this: Sense and Sensibility | Austen | 10 Harry Potter and the Sorcerer's Stone | Rowling | 20 The Lord of the Rings | Tolkien | 22 To Kill a Mockingbird | Lee | 12 --output omitted-- Of course, this situation is not ideal, since everybody can edit (but not delete) Michel's files. You can solve this problem using special access modes on the script file, see SUID and SGID in the Introduction to Linux guide. Redirection and file descriptors General As you know from basic shell usage, input and output of a command may be redirected before it is executed, using a special notation - the redirection operators - interpreted by the shell. Redirection may also be used to open and close files for the current shell execution environment. Redirection can also occur in a script, so that it can receive input from a file, for instance, or send output to a file. Later, the user can review the output file, or it may be used by another script as input. File input and output are accomplished by integer handles that track all open files for a given process. These numeric values are known as file descriptors. The best known file descriptors are stdin, stdout and stderr, with file descriptor numbers 0, 1 and 2, respectively. These numbers and respective devices are reserved. Bash can take TCP or UDP ports on networked hosts as file descriptors as well. The output below shows how the reserved file descriptors point to actual devices: michel ~> ls /dev/std* lrwxrwxrwx 1 root root 17 Oct 2 07:46 /dev/stderr -> ../proc/self/fd/2 lrwxrwxrwx 1 root root 17 Oct 2 07:46 /dev/stdin -> ../proc/self/fd/0 lrwxrwxrwx 1 root root 17 Oct 2 07:46 /dev/stdout -> ../proc/self/fd/1 michel ~> ls /proc/self/fd/[0-2] lrwx------ 1 michel michel 64 Jan 23 12:11 /proc/self/fd/0 -> /dev/pts/6 lrwx------ 1 michel michel 64 Jan 23 12:11 /proc/self/fd/1 -> /dev/pts/6 lrwx------ 1 michel michel 64 Jan 23 12:11 /proc/self/fd/2 -> /dev/pts/6 Note that each process has its own view of the files under /proc/self, as it is actually a symbolic link to /proc/<process_ID>. You might want to check info MAKEDEV and info proc for more information about /proc subdirectories and the way your system handles standard file descriptors for each running process. When excuting a given command, the following steps are excuted, in order: If the standard output of a previous command is being piped to the standard input of the current command, then /proc/<current_process_ID>/fd/0 is updated to target the same anonymous pipe as /proc/<previous_process_ID/fd/1. If the standard output of the current command is being piped to the standard input of the next command, then /proc/<current_process_ID>/fd/1 is updated to target another anonymous pipe. Redirection for the current command is processed from left to right. Redirection N>&M or N<&M after a command has the effect of creating or updating the symbolic link /proc/self/fd/N with the same target as the symbolic link /proc/self/fd/M. The redirections N> file and N< file have the effect of creating or updating the symbolic link /proc/self/fd/N with the target file. File descriptor closure N>&- has the effect of deleting the symbolic link /proc/self/fd/N. Only now is the current command executed. When you run a script from the command line, nothing much changes because the child shell process will use the same file descriptors as the parent. When no such parent is available, for instance when you run a script using the cron facility, the standard file descriptors are pipes or other (temporary) files, unless some form of redirection is used. This is demonstrated in the example below, which shows output from a simple at script: michel ~> date Fri Jan 24 11:05:50 CET 2003 michel ~> at 1107 warning: commands will be executed using (in order) a) $SHELL b) login shell c)/bin/sh at> ls /proc/self/fd/ > /var/tmp/fdtest.at at> <EOT> job 10 at 2003-01-24 11:07 michel ~> cat /var/tmp/fdtest.at total 0 lr-x------ 1 michel michel 64 Jan 24 11:07 0 -> /var/spool/at/!0000c010959eb (deleted) l-wx------ 1 michel michel 64 Jan 24 11:07 1 -> /var/tmp/fdtest.at l-wx------ 1 michel michel 64 Jan 24 11:07 2 -> /var/spool/at/spool/a0000c010959eb lr-x------ 1 michel michel 64 Jan 24 11:07 3 -> /proc/21949/fd And one with cron: michel ~> crontab # DO NOT EDIT THIS FILE - edit the master and reinstall. # (/tmp/crontab.21968 installed on Fri Jan 24 11:30:41 2003) # (Cron version -- $Id$) 32 11 * * * ls -l /proc/self/fd/ > /var/tmp/fdtest.cron michel ~> cat /var/tmp/fdtest.cron total 0 lr-x------ 1 michel michel 64 Jan 24 11:32 0 -> pipe:[124440] l-wx------ 1 michel michel 64 Jan 24 11:32 1 -> /var/tmp/fdtest.cron l-wx------ 1 michel michel 64 Jan 24 11:32 2 -> pipe:[124441] lr-x------ 1 michel michel 64 Jan 24 11:32 3 -> /proc/21974/fd Redirection of errors From the previous examples, it is clear that you can provide input and output files for a script (see for more), but some tend to forget about redirecting errors - output which might be depended upon later on. Also, if you are lucky, errors will be mailed to you and eventual causes of failure might get revealed. If you are not as lucky, errors will cause your script to fail and won't be caught or sent anywhere, so that you can't start to do any worthwhile debugging. When redirecting errors, note that the order of precedence is significant. For example, this command, issued in /var/spool ls * 2> /var/tmp/unaccessible-in-spool will redirect standard output of the ls command to the file unaccessible-in-spool in /var/tmp. The command ls * > /var/tmp/spoollist 2>&1 will direct both standard input and standard error to the file spoollist. The command ls * 2 >& 1 > /var/tmp/spoollist directs only the standard output to the destination file, because the standard error is copied to standard output before the standard output is redirected. For convenience, errors are often redirected to /dev/null, if it is sure they will not be needed. Hundreds of examples can be found in the startup scripts for your system. Bash allows for both standard output and standard error to be redirected to the file whose name is the result of the expansion of FILE with this construct: &> FILE This is the equivalent of > FILE 2>&1, the construct used in the previous set of examples. It is also often combined with redirection to /dev/null, for instance when you just want a command to execute, no matter what output or errors it gives. File input and output Using /dev/fd The /dev/fd directory contains entries named 0, 1, 2, and so on. Opening the file /dev/fd/N is equivalent to duplicating file descriptor N. If your system provides /dev/stdin, /dev/stdout and /dev/stderr, you will see that these are equivalent to /dev/fd/0, /dev/fd/1 and /dev/fd/2, respectively. The main use of the /dev/fd files is from the shell. This mechanism allows for programs that use pathname arguments to handle standard input and standard output in the same way as other pathnames. If /dev/fd is not available on a system, you'll have to find a way to bypass the problem. This can be done for instance using a hyphen (-) to indicate that a program should read from a pipe. An example: michel ~> filter body.txt.gz | cat header.txt - footer.txt This text is printed at the beginning of each print job and thanks the sysadmin for setting us up such a great printing infrastructure. Text to be filtered. This text is printed at the end of each print job. The cat command first reads the file header.txt, next its standard input which is the output of the filter command, and last the footer.txt file. The special meaning of the hyphen as a command-line argument to refer to the standard input or standard output is a misconception that has crept into many programs. There might also be problems when specifying hyphen as the first argument, since it might be interpreted as an option to the preceding command. Using /dev/fd allows for uniformity and prevents confusion: michel ~> filter body.txt | cat header.txt /dev/fd/0 footer.txt | lp In this clean example, all output is additionally piped through lp to send it to the default printer. Read and exec Assigning file descriptors to files Another way of looking at file descriptors is thinking of them as a way to assign a numeric value to a file. Instead of using the file name, you can use the file descriptor number. The exec built-in command can be used to replace the shell of the current process or to alter the file descriptors of the current shell. For example, it can be used to assign a file descriptor to a file. Use exec fdN> file for assigning file descriptor N to file for output, and exec fdN< file for assigning file descriptor N to file for input. After a file descriptor has been assigned to a file, it can be used with the shell redirection operators, as is demonstrated in the following example: michel ~> exec 4> result.txt michel ~> filter body.txt | cat header.txt /dev/fd/0 footer.txt >& 4 michel ~> cat result.txt This text is printed at the beginning of each print job and thanks the sysadmin for setting us up such a great printing infrastructure. Text to be filtered. This text is printed at the end of each print job. File descriptor 5 Using this file descriptor might cause problems, see the Advanced Bash-Scripting Guide, chapter 16. You are strongly advised not to use it. Read in scripts The following is an example that shows how you can alternate between file input and command line input: michel ~/testdir> cat sysnotes.sh #!/bin/bash # This script makes an index of important config files, puts them together in # a backup file and allows for adding comment for each file. CONFIG=/var/tmp/sysconfig.out rm "$CONFIG" 2>/dev/null echo "Output will be saved in $CONFIG." # create fd 7 with same target as fd 0 (save stdin "value") exec 7<&0 # update fd 0 to target file /etc/passwd exec < /etc/passwd # Read the first line of /etc/passwd read rootpasswd echo "Saving root account info..." echo "Your root account info:" >> "$CONFIG" echo $rootpasswd >> "$CONFIG" # update fd 0 to target fd 7 target (old fd 0 target); delete fd 7 exec 0<&7 7<&- echo -n "Enter comment or [ENTER] for no comment: " read comment; echo $comment >> "$CONFIG" echo "Saving hosts information..." # first prepare a hosts file not containing any comments TEMP="/var/tmp/hosts.tmp" cat /etc/hosts | grep -v "^#" > "$TEMP" exec 7<&0 exec < "$TEMP" read ip1 name1 alias1 read ip2 name2 alias2 echo "Your local host configuration:" >> "$CONFIG" echo "$ip1 $name1 $alias1" >> "$CONFIG" echo "$ip2 $name2 $alias2" >> "$CONFIG" exec 0<&7 7<&- echo -n "Enter comment or [ENTER] for no comment: " read comment; echo $comment >> "$CONFIG" rm "$TEMP" michel ~/testdir> sysnotes.sh Output will be saved in /var/tmp/sysconfig.out. Saving root account info... Enter comment or [ENTER] for no comment: hint for password: blue lagoon Saving hosts information... Enter comment or [ENTER] for no comment: in central DNS michel ~/testdir> cat /var/tmp/sysconfig.out Your root account info: root:x:0:0:root:/root:/bin/bash hint for password: blue lagoon Your local host configuration: 127.0.0.1 localhost.localdomain localhost 192.168.42.1 tintagel.kingarthur.com tintagel in central DNS Closing file descriptors Since child processes inherit open file descriptors, it is good practice to close a file descriptor when it is no longer needed. This is done using the exec fd<&- syntax. In the above example, file descriptor 7, which has been assigned to standard input, is closed each time the user needs to have access to the actual standard input device, usually the keyboard. The following is a simple example redirecting only standard error to a pipe: michel ~> cat listdirs.sh #!/bin/bash # This script prints standard output unchanged, while standard error is # redirected for processing by awk. INPUTDIR="$1" # fd 6 targets fd 1 target (console out) in current shell exec 6>&1 # fd 1 targets pipe, fd 2 targets fd 1 target (pipe), # fd 1 targets fd 6 target (console out), fd 6 closed, execute ls ls "$INPUTDIR"/* 2>&1 >&6 6>&- \ # Closes fd 6 for awk, but not for ls. | awk 'BEGIN { FS=":" } { print "YOU HAVE NO ACCESS TO" $2 }' 6>&- # fd 6 closed for current shell exec 6>&- <emphasis>Here</emphasis> documents Frequently, your script might call on another program or script that requires input. The here document provides a way of instructing the shell to read input from the current source until a line containing only the search string is found (no trailing blanks). All of the lines read up to that point are then used as the standard input for a command. The result is that you don't need to call on separate files; you can use shell-special characters, and it looks nicer than a bunch of echo's: michel ~> cat startsurf.sh #!/bin/bash # This script provides an easy way for users to choose between browsers. echo "These are the web browsers on this system:" # Start here document cat << BROWSERS mozilla links lynx konqueror opera netscape BROWSERS # End here document echo -n "Which is your favorite? " read browser echo "Starting $browser, please wait..." $browser & michel ~> startsurf.sh These are the web browsers on this system: mozilla links lynx konqueror opera netscape Which is your favorite? opera Starting opera, please wait... Although we talk about a here document, it is supposed to be a construct within the same script. This is an example that installs a package automatically, eventhough you should normally confirm: #!/bin/bash # This script installs packages automatically, using yum. if [ $# -lt 1 ]; then echo "Usage: $0 package." exit 1 fi yum install $1 << CONFIRM y CONFIRM And this is how the script runs. When prompted with the Is this ok [y/N] string, the script answers y automatically: [root@picon bin]# ./install.sh tuxracer Gathering header information file(s) from server(s) Server: Fedora Linux 2 - i386 - core Server: Fedora Linux 2 - i386 - freshrpms Server: JPackage 1.5 for Fedora Core 2 Server: JPackage 1.5, generic Server: Fedora Linux 2 - i386 - updates Finding updated packages Downloading needed headers Resolving dependencies Dependencies resolved I will do the following: [install: tuxracer 0.61-26.i386] Is this ok [y/N]: EnterDownloading Packages Running test transaction: Test transaction complete, Success! tuxracer 100 % done 1/1 Installed: tuxracer 0.61-26.i386 Transaction(s) Complete
Summary In this chapter, we learned how to provide user comments and how to prompt for user input. This is usually done using the echo/read combination. We also discussed how files can be used as input and output using file descriptors and redirection, and how this can be combined with getting input from the user. We stressed the importance of providing ample message for the users of our scripts. As always when others use your scripts, it is better to give too much information than not enough. Here documents is a type of shell construct that allows creation of lists, holding choices for the users. This construct can also be used to execute otherwise interactive tasks in the background, without intervention. Exercises These exercises are practical applications of the constructs discussed in this chapter. When writing the scripts, you may test by using a test directory that does not contain too much data. Write each step, then test that portion of code, rather than writing everything at once. Write a script that asks for the user's age. If it is equal to or higher than 16, print a message saying that this user is allowed to drink alcohol. If the user's age is below 16, print a message telling the user how many years he or she has to wait before legally being allowed to drink. As an extra, calculate how much beer an 18+ user has drunk statistically (100 liters/year) and print this information for the user. Write a script that takes one file as an argument. Use a here document that presents the user with a couple of choices for compressing the file. Possible choices could be gzip, bzip2, compress and zip. Write a script called homebackup that automates tar so the person executing the script always uses the desired options () and backup destination directory (/var/backups) to make a backup of his or her home directory. Implement the following features: Test for the number of arguments. The script should run without arguments. If any arguments are present, exit after printing a usage message. Determine whether the backups directory has enough free space to hold the backup. Ask the user whether a full or an incremental backup is wanted. If the user does not have a full backup file yet, print a message that a full backup will be taken. In case of an incremental backup, only do this if the full backup is not older than a week. Compress the backup using any compression tool. Inform the user that the script is doing this, because it might take some time, during which the user might start worrying if no output appears on the screen. Print a message informing the user about the size of the compressed backup. See info tar or Introduction to Linux, chapter 9: Preparing your data for background information. Write a script called simple-useradd.sh that adds a local user to the system. This script should: Take only one argument, or else exit after printing a usage message. Check /etc/passwd and decide on the first free user ID. Print a message containing this ID. Create a private group for this user, checking the /etc/group file. Print a message containing the group ID. Gather information from the operator user: a comment describing this user, choice from a list of shells (test for acceptability, else exit printing a message), expiration date for this account, extra groups of which the new user should be a member. With the obtained information, add a line to /etc/passwd, /etc/group and /etc/shadow; create the user's home directory (with correct permissions!); add the user to the desired secondary groups. Set the password for this user to a default known string. Rewrite the script from so that it reads input from the user instead of taking it from the first argument.