Thuta Learning
ExercisesDevOps & Toolsbeginner

Exercise: Build a CLI Tool with Subcommands

What you'll walk away with

  • Explain the core ideas behind Exercise: Build a CLI Tool with Subcommands
  • Run the sample code and verify its output
  • Apply the technique correctly to the Tutorial Platform and production scenarios

Build the mental model

Most real command-line tools you use every day — git, npm, docker — aren't a single flat command, they're a dispatcher: the first argument names a subcommand, and everything after it belongs to that subcommand. This scaffold builds that exact shape in miniature. case "$subcommand" in ... esac is the idiomatic bash way to do the matching, because unlike a chain of if/elif/fi comparisons, a case block reads as a flat list of "when the value is this, do that" branches — it scales to a dozen subcommands without turning into a wall of brackets, and it supports pattern matching (globs, alternation with |) that a plain string comparison doesn't. The shift right before the case is just as load-bearing as the case itself: without it, "$@" inside cmd_add would still start with the subcommand name, so every handler would have to know its own name and manually skip past it. With shift, each handler function receives exactly the arguments meant for it, as if it were its own independent script — which is also why wrapping each subcommand in its own function (cmd_list, cmd_add, cmd_done) rather than inlining logic into the case branches keeps the dispatcher itself short and lets you test or reuse each handler separately. What this scaffold doesn't yet solve is state: cmd_list works because it reads a hardcoded array that lives in memory only for the lifetime of one script run, but a real task manager needs to remember what you added yesterday, and memory doesn't survive the script process exiting — that's a genuinely different kind of problem from anything earlier lessons dealt with, because every previous exercise ran, printed something, and was done.

Connect it to a real scenario

The scaffold you're handed already proves the dispatcher works: run ./task.sh list and main reads "list" into subcommand, shifts it off, matches it in the case block, and calls cmd_list, which loops over the hardcoded TASKS array and prints each entry with a 1-based index. Run ./task.sh add "Buy bread" and the same dispatch machinery correctly routes you into cmd_add with "Buy bread" as "$1" — but the function body is just a TODO and an echo, so nothing happens to the list. Your job is to make cmd_add and cmd_done do real work while leaving main and the case statement untouched, since they're already correct. For cmd_add, the key realization is that appending "$1" to the in-memory TASKS array would only ever be visible for the rest of this one process — the moment the script exits, that array is gone, so you need to append the new task text as a line in a file on disk (something like a tasks.txt next to the script) so a future invocation can read it back. For cmd_done "$1", think about what safely "removing" a line from a file means in bash: you'll typically read the file, filter out the line matching the given task number, and rewrite the file, or mark that line done in place rather than deleting it mid-read. Once both are wired to the same persisted file, cmd_list also needs to read from that file instead of the hardcoded array, so all three subcommands agree on one source of truth across separate runs of the script.

Try the working example

bash
#!/usr/bin/env bash
# task.sh - a tiny CLI with subcommands (add, list, done)
# Usage: ./task.sh <command> [arguments]

# Pretend this is loaded from a real task file; hardcoded here for the demo
TASKS=("Buy milk" "Write report" "Call the plumber")

cmd_list() {
  echo "Tasks:"
  local i=1
  for t in "${TASKS[@]}"; do
    echo "  $i) $t"
    i=$((i + 1))
  done
}

cmd_add() {
  # TODO: append "$1" to TASKS and persist it so future runs see it
  echo "add: not yet implemented"
}

cmd_done() {
  # TODO: mark task number "$1" as complete (e.g. remove it or flag it)
  echo "done: not yet implemented"
}

main() {
  local subcommand="$1"
  shift

  case "$subcommand" in
    list)
      cmd_list
      ;;
    add)
      cmd_add "$@"
      ;;
    done)
      cmd_done "$@"
      ;;
    *)
      echo "Usage: task.sh {add|list|done} [arguments]" >&2
      exit 1
      ;;
  esac
}

main "$@"
You should see
$ ./task.sh list
Tasks:
  1) Buy milk
  2) Write report
  3) Call the plumber

$ ./task.sh add "Buy bread"
add: not yet implemented

$ ./task.sh foo
Usage: task.sh {add|list|done} [arguments]

5-minute try-it

Fill in cmd_add so that ./task.sh add "New task" appends the given text as a new task that shows up the next time you run ./task.sh list — since each invocation starts a fresh process, that means persisting tasks to a file rather than only appending to the in-memory TASKS array. Then fill in cmd_done so ./task.sh done <number> marks that task finished (by removing it or flagging it — your choice) in the same persisted file. When you're finished, list should print the real current tasks instead of the hardcoded three, add and done should no longer print "not yet implemented", and running an unrecognized subcommand should still fall through to the usage message exactly as it does now.

One important caution

Implementing add and done as changes to the in-memory TASKS array only — every invocation of the script is a fresh process, so nothing persists to the next run unless it's written to a file

Forgetting the shift after reading $1 as the subcommand — without it, $@ inside cmd_add or cmd_done still includes the subcommand name itself as an unwanted extra argument

GNU Bash Reference Manual — Conditional Constructs (case)Bash / Shell Scripting

Easy traps

  • Implementing add and done as changes to the in-memory TASKS array only — every invocation of the script is a fresh process, so nothing persists to the next run unless it's written to a file
  • Forgetting the shift after reading $1 as the subcommand — without it, $@ inside cmd_add or cmd_done still includes the subcommand name itself as an unwanted extra argument
  • Validate sample code in a local or test environment before applying it to a production system.

Exercise

Fill in cmd_add so that ./task.sh add "New task" appends the given text as a new task that shows up the next time you run ./task.sh list — since each invocation starts a fresh process, that means persisting tasks to a file rather than only appending to the in-memory TASKS array. Then fill in cmd_done so ./task.sh done <number> marks that task finished (by removing it or flagging it — your choice) in the same persisted file. When you're finished, list should print the real current tasks instead of the hardcoded three, add and done should no longer print "not yet implemented", and running an unrecognized subcommand should still fall through to the usage message exactly as it does now.

You'll know it worked when: $ ./task.sh list Tasks: 1) Buy milk 2) Write report 3) Call the plumber $ ./task.sh add "Buy bread" add: not yet implemented $ ./task.sh foo Usage: task.sh {add|list|done} [arguments]

Exercise: Build a CLI Tool with Subcommands | Thuta Learning