TDD a CLI Caching Script - Part Three - Help

This is part three in a series about writing a general-purpose script to cache CLI output. In part two we added support for a TTL option and specifying acceptable status codes to cache.

In this post, we'll add support for --help and improve our option parsing.

--help

We're already parsing options in our script, so responding to --help isn't much work.

What should --help do? It should give basic usage instructions and document each option our script can take. We can write a simple test to that end.

@test "documents options with --help" {
  run ./cache --help
  [ "$status" -eq 0 ]
  echo $output | grep -- --ttl
  echo $output | grep -- --cache-status
  echo $output | grep -- --help
}

We're not making any specific claims about what the help documentation tells us about each option. We're only asserting that every option is mentioned (including --help itself).

The double-dash after grep is important in these assertions. In grep -- --ttl, the -- tells grep we're not trying to pass --ttl as an option, but that it should be interpreted as a positional argument (in this case, the thing we want to search for). There's an implication for our cache script here too that we'll address shortly.

In the meantime, let's make this test pass by responding to --help.

We'll add a usage function after set -e and before we parse our options:

usage() {
    echo "usage: cache [--ttl SECONDS] [--cache-status CACHEABLE-STATUSES] [cache-key] [command] [args for command]"
    echo "  --ttl SECONDS   # Treat previously cached content as fresh if fewer than SECONDS seconds have passed"
    echo "  --cache-status  # Quoted and space-delimited exit statuses for [command] that are acceptable to cache."
    echo "  --help      # show this help documentation"
}

Within our option parsing, we'll add a case for --help to call usage and exit.

        --help)
            usage
            exit 0
            ;;

Here's what the output of cache --help looks like:

usage: cache [--ttl SECONDS] [--cache-status CACHEABLE-STATUSES] [cache-key] [command] [args for command]
        --ttl SECONDS   # Treat previously cached content as fresh if fewer than SECONDS seconds have passed
        --cache-status  # Quoted and space-delimited exit statuses for [command] that are acceptable to cache.
        --help          # show this help documentation

Not bad.

Better positional argument parsing

Remember that bit about -- and positional arguments in the grep usage? Anything after -- is treated as a positional argument even if it looks like an option. Let's think about why that matters. Imagine we're trying to cache the --help output of a command. That may sound silly, but it'll illustrate our point. Consider cache some-cache-key grep --help. Can you guess what the output will be?

If you guessed the --help content for our cache script, you're right. Our cache script sees --help anywhere in the command and says "Ah, I know how to do that!" That's not ideal.

We should support the -- like we see in this updated command: cache -- some-cache-key grep --help

We'll write a test:

@test "stops parsing arguments after --" {
  run ./cache -- $TEST_KEY grep --help
  [ "$status" -eq 2 ]
  echo $output | grep -- "usage: grep"
}

Note the expectation that the status will be 2. That's the code grep --help exists with. It is a little strange, perhaps, since the command didn't exactly fail. But it didn't exactly succeed either. git --help exits with 0. I'm fine with cache --help exiting with 0, but this is an idiosyncrasy worth knowing about.

This test fails as every good initial test should. Our $status is 0 and our output is cache --help.

Fortunately our option-parsing code lends itself well to handling --. We'll add the following case:

        --)
            shift
            positional+=("$@")
            break
            ;;

And we're green. And now we're a better citizen of the command line.

Flexible Parsing and Confusion

Handling the double-dash is the right thing to do. But cache some-cache-key grep --help without the double-dash seems pretty reasonable too. We really only have two types of positional arguments: the cache key, and the command and its arguments.

Passing options before the cache key positional argument like cache --ttl 1 some-cache-key grep --help seems valid. So does passing options after the cache key positional argument like cache some-cache-key --ttl 1 grep --help.

What doesn't make sense is providing options after the command starts. Something like cache some-cache-key grep --ttl 1 --help shouldn't be interpreted as providing a TTL of 1 to our cache script.

We could explicitly stop parsing options after our cache key. We'd essentially be treating this as an implicit --. But is this worth the effort or should we trust the user to do the right thing? That's a classic question, right? You need to weigh the value of protecting against weird input against the complexity the protection adds to the code and the difficulty of implementation. Also there's the nebulous question of how much effort it'll take to support this feature as time marches on.

In this case, I'm learning in my free time, so I'll go the extra mile to see how it pans out.

First let's add a test illustrating how we already parse options before or after the cache key. This test will keep us from accidentally breaking things:

@test "parses options before and after the cache key" {
  # fails because the status isn't allowed by our options
  run ./cache --cache-status "2" $TEST_KEY exit 0
  [ "$status" -eq 0 ]
  [ ! -f "$TMPDIR$TEST_KEY" ]

  # succeeds because the status is allowed by our option before the
  # cache key
  run ./cache --cache-status "0 2" $TEST_KEY exit 2
  [ "$status" -eq 2 ]
  [ -f "$TMPDIR$TEST_KEY" ]

  rm "$TMPDIR$TEST_KEY"

  # succeeds because the status is allowed by our option after the
  # cache key
  run ./cache $TEST_KEY --cache-status "0 2" exit 2
  [ "$status" -eq 2 ]
  [ -f "$TMPDIR$TEST_KEY" ]
}

That already passes. Now let's add a failing test.

@test "stops parsing options after the command starts" {
  run ./cache $TEST_KEY echo --ttl 1 --help
  [ "$status" -eq 0 ]
  [ $output = "--ttl 1 --help" ]
}

This fails because we're still parsing --ttl 1 and --help after the command (echo) starts. That means that $output is the result of cache --help.

We'll update the default branch of our case statement.

        *) # default
            if [ -z "$cache_key" ]; then
                cache_key=$1
                shift
            else
                break;
            fi
            ;;

If the $cache_key isn't set, we set it and remove it from the argument list. If it is set, we break because we only want to parse one positional argument (for the cache key) and treat all remaining arguments as the provided command and its args.

Next we'll replace the -- branch of the case statement with

        --)
            cache_key=$2
            shift # drop the --
            shift # drop the cache key
            break
            ;;

We can now remove these lines after the case statement

set -- "${positional[@]}" # restore positional parameters
cache_key=$1
shift

and also remove any other references the positional variable since we are no longer using it.

 ✓ initial run is uncached
 ✓ works for quoted arguments
 ✓ preserves the status code of the original command
 ✓ subsequent runs are cached
 ✓ respects a TTL
 ✓ only caches 0 exit status by default
 ✓ allows specifying exit statuses to cache
 ✓ allows specifying * to allow caching all statuses
 ✓ returns the cached exit status
 ✓ documents options with --help
 ✓ stops parsing arguments after --
 ✓ parses options before and after the cache key
 ✓ stops parsing options after the command starts

13 tests, 0 failures

All this work might have you questioning if supporting positional arguments is worth it. We could require the cache key to be provided as --key. This is an ergonomics versus aesthetic argument that ultimately comes down to opinion. Do whatever you feel is best and document accordingly.

Closing

You might be asking: --help is nice, but what about writing a man page? I haven't taken the plunge here yet, but if I were going to try it, I'd probably lean on (the always wonderful) pandoc. Search for "Man page" on their demos.

Here's the diff for adding --help and -- support and the diff for not parsing options after the command starts.

Stay tuned for our next post where we'll take a quick look at a shell script linter that can help us avoid errors and improve code quality.


TDD a CLI Caching Script - Part Two - Option Parsing, TTL, and Exit Codes

This is part two in a series about writing a general-purpose script to cache CLI output. In part one we wrote our first iteration of the script. It works for permanently caching content.

In this post, we'll add support for a TTL to specify when the content is no longer fresh. We'll also update the script to only cache successful runs of the provided command.

Let's parse some options

Our cache script uses positional arguments. Positional arguments can be great when they're few but as the number of arguments grows, they can lead to ambiguity in parsing and confusion for the user.

We'll support the TTL as an option specified by --ttl NUMBER_OF_SECONDS. Example usage:

cache --ttl 90 cache-key ping -c 3 google.com

After the first run, this will return cached content for any subsequent runs in 90 seconds.

We write a naive test for the TTL cache.

@test "respects a TTL" {
  run ./cache --ttl 1 $TEST_KEY echo initial-value
  [ "$status" -eq 0 ]
  [ $output = "initial-value" ]

  run ./cache --ttl 1 $TEST_KEY echo new-value
  [ "$status" -eq 0 ]
  [ $output = "initial-value" ]

  sleep 1

  run ./cache --ttl 1 $TEST_KEY echo third-value
  [ "$status" -eq 0 ]
  [ $output = "third-value" ]
}

This caches an initial-value with a TTL of 1 second and then immediately tries to run for the same cache-key within that window. Because the TTL hasn't expired, the cached initial-value is returned. Next we sleep 1 second and the TTL has expired. The cached content is no longer valid so we execute our echo command, third-value is returned, and third-value is now cached.

(I don't love sleeping in tests, but this is the easiest way to test this.)

The test fails because our exit status is 127. That's "command not found." The problem here is that our positional arguments mean we're parsing --ttl as the cache key. Then we're trying to execute 1 cache-tests-key echo initial-value as our command. That won't work.

Worse, our invalid argument parsing means we've left behind a temporary file our script doesn't know how to clean up. rm $TMPDIR/--ttl puts us back in a clean state.

There's a number of ways to parse options in bash scripts. getopts is compelling but it only allows for single character flags and that feels a little restrictive. I could brew install gnu-getopt to get a getopt that supports long flag names, but the usage is a little opaque.

We'll go with a straightforward while [[ $# -gt 0 ]] with a case statement as suggested here.

We modify our cache script to look like this

#!/usr/bin/env bash

set -e

positional=()
while [[ $# -gt 0 ]]
do
    key="$1"

    case $key in
        --ttl)
            ttl="$2"
            shift # drop the key
            shift # drop the value
            ;;
        *)    # default
            positional+=("$1") # save it in an array for later
            shift # drop the argument
            ;;
    esac
done

set -- "${positional[@]}" # restore positional parameters
cache_key=$1
shift

cache_dir=${CACHE_DIR:-$TMPDIR}
cache_file="$cache_dir$cache_key"

if test -f $cache_file; then
    cat $cache_file
else
    "$@" | tee $cache_file
    exit ${PIPESTATUS[0]}
fi

We're draining our arguments (with shift) until none remain. If we find the --ttl we extract it. Otherwise we keep the positional arguments and set those as our argument list ($@).

bats test now shows the test now fails because the initial-value remains even after the content should have expired. That's because we haven't implemented cache expiration yet.

Cache expiration

Let's list the circumstances where a cache hit is valid:

  1. The cache file exists and no TTL is specified
  2. The cache file exists and the TTL has not expired

How do we tell if the TTL is expired? When we write the cache file, we're implicitly keeping track of the timestamp when the content was cached in the modified timestamp attribute of the cache file. If the current time is less than the modified timestamp + our TTL seconds, then the cache is still valid. Otherwise it is expired.

We can use stat to get the modified time of a file and date to get the system time. stat -f %m <FILENAME> and date +%s both return an epoch time which makes for easy integer math.

Let's update our concept of freshness to consider the TTL:

We'll replace

if test -f $cache_file; then

with

fresh () {
    # if the $cache_file doesn't exist, it can't be fresh
    if [ ! -f $cache_file ]; then
        return 1
    fi

    # if we don't have a ttl specifed, our $cache_file is
    # fresh-enough
    if [ -z "$ttl" ]; then
        return 0
    fi

    # if a ttl is specified, we need to check the last modified
    # timestamp on the $cache_file
    mtime=$(stat -f %m $cache_file)
    now=$(date +%s)
    remaining_time=$(($now - $mtime))

    if [ $remaining_time -lt $ttl ]; then
        return 0
    fi

    return 1
}

if fresh; then

In some languages, zero is falsy and non-zero numbers are truthy. But in shell scripts, as you've seen in exit statuses, zero indicates success and non-zero indicates failure. Writing our function to be consistent with other expected values means we can easily swap in our function for the previous test conditional.

Our fresh function returns a truthy value if the cache file exists and either no TTL is specified or the TTL hasn't passed.

Running bats test shows everything passing:

 ✓ initial run is uncached
 ✓ works for quoted arguments
 ✓ preserves the status code of the original command
 ✓ subsequent runs are cached
 ✓ respects a TTL

5 tests, 0 failures

Caching successful runs only

Our current script caches regardless of the exit status of the provided command. In some cases, this is what you want because the status won't change on subsequent runs. But if the command can fail intermittently or the status can change over time, then you might only want to cache certain statuses.

hub ci-status, for example, can return an exit status of 2 while a GitHub Check is pending. Neither the hub ci-status command nor the GitHub check has failed. A subsequent run could exit with another 2 if it is still pending or exit with a 0 or a 1 for the final outcome.

It feels like a good default is to only cache on zero exit statuses. This default is least likely to unintentionally cache bad content. We can then add an option for specifying other acceptable statuses to cache.

We'll add an initial test:

@test "only caches 0 exit status by default" {
  run ./cache $TEST_KEY exit 1
  [ "$status" -eq 1 ]
  [ ! -f "$TMPDIR$TEST_KEY" ]

  run ./cache $TEST_KEY exit 0
  [ "$status" -eq 0 ]
  [ -f "$TMPDIR$TEST_KEY" ]
}

This fails since we cache everything. We'll update our script to make this pass by replacing

    "$@" | tee $cache_file
    exit ${PIPESTATUS[0]}

with

    "$@" | tee $cache_file
    status=${PIPESTATUS[0]}

    if [[ $status -ne 0 ]]; then
        rm $cache_file
    fi

    exit $status

We're actually still writing the cache file, we're just deleting it immediately if the exit status wasn't valid. This could be optimized for a conditional write, but the ease of using tee here makes this conditional rm acceptable to me.

That test passes, so let's add a new test for specifying acceptable status codes. We're going to pass these in with --cache-status followed by space-separated statuses we want to allow to be cached. We'll need to quote the statuses if there's more than one.

@test "allows specifying exit statuses to cache" {
  run ./cache --cache-status "1 2" $TEST_KEY exit 0
  [ "$status" -eq 0 ]
  [ ! -f "$TMPDIR$TEST_KEY" ]

  run ./cache --cache-status "1 2" $TEST_KEY exit 1
  [ "$status" -eq 1 ]
  [ -f "$TMPDIR$TEST_KEY" ]

  rm "$TMPDIR$TEST_KEY"

  run ./cache --cache-status "1 2" $TEST_KEY exit 2
  [ "$status" -eq 2 ]
  [ -f "$TMPDIR$TEST_KEY" ]
}

This initially fails because we aren't parsing our option yet. We'll update our case statement to look like this:

    case $key in
        --ttl)
            ttl="$2"
            shift # drop the key
            shift # drop the value
            ;;
        --cache-status)
            acceptable_statuses="$2"
            shift # drop the key
            shift # drop the value
            ;;
        *)    # default
            positional+=("$1") # save it in an array for later
            shift # drop the argument
            ;;
    esac

Now our test fails because we're not considering the acceptable_statuses. We can replace the line

if [[ $status -ne 0 ]]; then

with

    acceptable_statuses=${acceptable_statuses:-0}
    if [[ ! " $acceptable_statuses " =~ " $status " ]]; then

to make this pass. We're checking whether the actual exit status is in our list of acceptable_statuses (which is 0 by default). If not, we remove the $cache_file.

Finally, it would be nice to allow caching any status with --cache-status "*" so let's support that.

The test:

@test "allows specifying * to allow caching all statuses" {
  run ./cache --cache-status "*" $TEST_KEY exit 3
  [ "$status" -eq 3 ]
  [ -f "$TMPDIR$TEST_KEY" ]
}

And we'll tweak the line

    if [[ ! " $acceptable_statuses " =~ " $status " ]]; then

to be

    if [[ $acceptable_statuses != "*" ]] && [[ ! " $acceptable_statuses " =~ " $status " ]]; then

And the test passes.

Returning the exit status of the cached command

That all works, but it is a little unfortunate that if we have a cache hit, we're always exiting with a status of 0. If we cache a command that exited with status of 2 and that 2 is meaningful, it would be better to return that original exit status.

We write a test:

@test "returns the cached exit status" {
  run ./cache --cache-status "*" $TEST_KEY exit 3
  [ "$status" -eq 3 ]

  run ./cache --cache-status "*" $TEST_KEY exit 9
  [ "$status" -eq 3 ]
}

That fails because we exit with the zero status on cache hits. Let's update the code. We'll add an else clause to our acceptable status check to persist the status code of the cached run:

    if [[ $acceptable_statuses != "*" ]] && [[ ! " $acceptable_statuses " =~ " $status " ]]; then
        rm $cache_file
    else
        echo $status > "$cache_file.cache-status"
    fi

Next we'll update the if fresh check to read that cached status:

if fresh; then
    status=$(cat "$cache_file.cache-status")
    cat $cache_file

Finally, we'll move our existing exit $status line to the bottom of the file since we always are exiting with a specific status now.

Closing

We've added a TTL and made our script smarter about what exit statuses are able to be cached. We're also returning the cached exit status now.

Here's the diff for adding the TTL and the diff for better handling status codes.

Because the command to run is only executed if the TTL has expired, you can use the cache script as a throttle/rate-limiter. The command cache --ttl 90 cache-key say "90 seconds have passed" run in a tight loop will only speak every 90 seconds.

Now that we're accepting options, we should probably respond to --help. We'll address that and more in part three.


TDD a CLI Caching Script - Part One

This is the first in a series about writing a general-purpose script to cache CLI output. In this series we'll learn about using bats to test CLI programs, level up our Bash skills, and hopefully end up with a useful tool we can use every day.

Goal

The end result script should work something like this:

cache cache-key script-name arg1 arg2 <additional args...>

  1. On first run, it invokes some-script-name with the arguments and caches the STDOUT result.
  2. On subsequent runs, it returns the cached content from the prior run.

Future versions of the cache script can incorporate a TTL, async refreshes, etc.

Why is this useful?

Caching allows us to do expensive work once and use the result until it is no longer timely. Some program results can be cached permanently because the content is easily fingerprinted to a unique cache key.

A real-world example is the rake routes (or rails routes) command. This command generates a list of available routes (think urls) in your application. Unfortunately, Rails has to essentially boot your entire app to generate this list. This takes longer and longer to do as your app grows.

If your Rails' route setup is traditional (single file, no surprising metaprogramming) then you can trust that your routes will only change if the config/routes.rb file changes. We can use md5 to get a simple string fingerprint of the file contents. We can use that fingerprint as a permanent cache key for the result of running rake routes because any changes to the routes will change the md5 and invalidate the cache.

This means that cache $(md5 config/routes.rb) rake routes can reliably cache the output and cut the time down from >10 seconds on a large app to essentially zero. This a huge difference if you're using this output for something like route-completion with fzf in Vim.

Writing our first test

Following TDD, we'll describe the behavior we wish our script had with tests. These tests will fail because the behavior doesn't exist yet. Then we'll implement just-enough functionality to make the test pass. We repeat this loop until our script is feature-complete.

First we install bats (from source or via brew install bats) and make a new directory for our script. Make a new directory cli-cache and give it a subdirectory of test.

Within the test directory, we'll make a new file named cache.bats and add our initial test:

@test "initial run is uncached" {
  run ./cache some-test-key echo hello
  [ "$status" -eq 0 ]
  [ $output = "hello" ]
}

run executes a given command, sets $output to the STDERR and STDOUT of the command, and sets $status to the status code of the command.

Our test is really just showing that the cache script can successfully execute the command we provide it and return the output. That's a small but important first step.

We can run the test with bats test from the cache directory.

 ✗ initial run is uncached
   (in test file test/cache.bats, line 3)
     `[ "$status" -eq 0 ]' failed

1 test, 1 failure

Hooray, our first failing test! The status code didn't match the expected code. If we put echo $status before our comparison, we'll see that $status is 127 which means the command is not found. That makes sense because we haven't made our cache script yet. Let's create an empty file named cache in the cli-cache folder and try again.

The test still fails, but now $status is 126 because the command isn't executable. chmod +x cache and try again.

 ✗ initial run is uncached
   (in test file test/cache.bats, line 4)
     `[ $output = "hello" ]' failed with status 2
   /var/folders/40/y21j3fw13432jk6z_y08mnbm0000gn/T/bats.59924.src: line 4: [: =: unary operator expected

1 test, 1 failure

The status code is fine now but our $output isn't what we want since our cache script doesn't do anything. Let's modify the cache script to run the command provided so the test will pass.

#!/usr/bin/env bash

set -e

cache_key=$1
shift

$@

We have a shebang line. We set -e so our script will fail at the first invalid command (this is generally a best practice).

Then we assign our $cache_key to the first argument. Next we shift to remove the $cache_key from our argument list. Now we can execute the provided command.

Rerunning bats test shows success. Nice work!

Add more tests to flesh out the implementation

Let's add a new test to verify that it works for quoted arguments to the provided command:

@test "works for quoted arguments" {
  run ./cache some-test-key printf "%s - %s\n" flounder fish
  [ "$status" -eq 0 ]
  [ $output = "flounder - fish" ]
}

Hrm. That didn't work. If we echo $output, we see -%s\nflounderfish -- all our arguments to printf smushed together. To preserve the arguments, we can update our cache script by changing $@ to the quoted form "$@".

With that passing, there's one more useful fundamental to get right: the cache command should return the exit code of the underlying command.

@test "preserves the status code of the original command" {
  run ./cache some-test-key exit 1
  [ "$status" -eq 1 ]
}

That one already passes for free by virtue of the "$@" being the last line of our script.

Now we have three passing tests, but we're not actually caching anything yet. We add a new test for the caching behavior.

@test "subsequent runs are cached" {
  run ./cache some-test-key echo initial-value
  [ "$status" -eq 0 ]
  [ $output = "initial-value" ]

  run ./cache some-test-key echo new-value
  [ "$status" -eq 0 ]
  [ $output = "initial-value" ]
}

Here we call echo twice with two different strings. Since our cache-key remains the same, the second echo should never get evaluated and our script should instead return the cached value from the first echo call.

With that test failing, let's update our script to do some caching.

#!/usr/bin/env bash

set -e

cache_key=$1
shift

cache_dir=${CACHE_DIR:-$TMPDIR}
cache_file="$cache_dir$cache_key"

if test -f $cache_file; then
    cat $cache_file
else
    "$@" | tee $cache_file
fi

Looks easy enough, right? If the cache file exists, we read it. Otherwise we execute the command and pipe it to tee. tee prints the output to STDOUT and also writes the output to our $cache_file.

You can specify the cache directory by setting the environment variable CACHE_DIR or we'll default to $TMPDIR.

Running our tests shows (perhaps) unexpected results:

 ✓ initial run is uncached
 ✗ works for quoted arguments
   (in test file test/cache.bats, line 18)
     `[ $output = "flounder - fish" ]' failed
 ✗ preserves the status code of the original command
   (in test file test/cache.bats, line 23)
     `[ "$status" -eq 1 ]' failed
 ✗ subsequent runs are cached
   (in test file test/cache.bats, line 29)
     `[ $output = "initial-value" ]' failed

4 tests, 3 failures

Wait, why is everything broken but the first test? Oh yeah, we're caching now and all the tests use the same cache-key. We could give each test a unique cache key, but instead let's use bats' setup function to ensure we delete cached content between tests.

setup() {
  export TEST_KEY="cache-tests-key"

  # clean up any old cache file (-f because we don't care if it exists or not)
  rm -f "$TMPDIR$TEST_KEY"
}

We'll replace anywhere we're using some-test-key in the tests with $TEST_KEY.

bats test now shows everything passing except the "preserves the status code of the original command" test. This is a side-effect of piping our command to tee. tee exits with a status code of 0 because tee worked fine (even though the preceding command did not). Fortunately we can use $PIPESTATUS to get the status of the any command in the pipe chain. We just need to add the line exit ${PIPESTATUS[0]} after our "$@" | tee $cache_file line.

 ✓ initial run is uncached
 ✓ works for quoted arguments
 ✓ preserves the status code of the original command
 ✓ subsequent runs are cached

4 tests, 0 failures

Closing

Here's the final version of the script:

#!/usr/bin/env bash

set -e

cache_key=$1
shift

cache_dir=${CACHE_DIR:-$TMPDIR}
cache_file="$cache_dir$cache_key"

if test -f $cache_file; then
    cat $cache_file
else
    "$@" | tee $cache_file
    exit ${PIPESTATUS[0]}
fi

You can add this to your $PATH to invoke cache from anywhere.

Let's compare timings of ways to invoke rake routes on a large app:

commandcache statusseconds
time rake routesno caching12
time spring rake routescold spring boot12
time spring rake routesspring fully-loaded3
time cache $(md5 -q config/routes.rb) rake routesuncached12
time cache $(md5 -q config/routes.rb) rake routescached0.02

With a small update to the source of our fzf route completion, things are super speedy!

inoremap <expr> <c-x><c-r> fzf#complete({
  \ 'source':  'cache $(md5 -q config/routes.rb) rake routes',
  \ 'reducer': '<sid>parse_route'})

If this all feels like a lot of work to save 12 seconds, you're right. From my experience, the value is rarely in the actual time saved, but in the preservation of flow. Any time I spend waiting on the computer is time when I can get distracted or otherwise lose my flow. In my career, I've observed that disruptions compound in the negative. I've found that eliminating them (where possible) can compound in the positive as well.

Now we have a new trick to eliminate disruptions and help preserve flow.

Up next

Check out part two where we add a TTL option to specify when cached content should expire. We'll also update the script to only cache successful runs of the provided command.

You can always find the most up-to-date version of the cache script on GitHub.


It may surprise you to hear that there isn't a standard unix utility to cache CLI script output. Thankfully, there's a number of community-provided examples to choose from. e.g. cachecmd, runcached, bash-cache, etc.

My goofy face

Hi, I'm Jeffrey Chupp.
I solve problems, often with code.