loading...
Cover image for Extendable heroes

Extendable heroes

moopet profile image Ben Sinclair Updated on ・5 min read

Extending things (2 Part Series)

1) Extendable heroes 2) Fuzzy branches: a brief example of a git custom command.

You know you can extend git with arbitrary commands? Did you ever want to do the same thing in your own programs?

When you use a git extension, like git flow, you can use it as a git subcommand. Like this:

git flow <action>

Have you ever wondered how git-flow adds itself as a plugin? It's not an alias, it's a fully-fledged command. Does the installer put it into a special directory? Does it register itself using an API?

Well, it's simpler than that:

$ which git-flow
/usr/bin/git-flow

It turns out that it's actually a perfectly normal command called git-flow, which git finds and passes control to, like magic. If there's an executable file anywhere in the path with the name git-<subcommand>, git knows it's in on the act. And yes, this is all in the git documentation.

Isn't that a neat system? Each command still has a single responsibility, and can be run directly, but is also presented as it it was part of the parent command.

So, can we do the same thing in our own apps? Yep. Of course we can!

I'm going to show you how, using (as my boss calls it) deeply unfashionable bash. And I'm going to do it from scratch. I mean, we could always look at the source for git and copy that (oh, the joys of free software!), but where's the fun?

What does our code need to do?

  • List available subcommands
  • Determine whether an argument is a valid subcommand
  • Pass arguments and environments to the validated subcommand

What do we need to do to make this happen?

  • A parent program. We'll call it frobulator
  • A separate subcommand we'll call demo

The subcommand will exist as a file somewhere on the user's $PATH called frobulator-demo and will be usable
with the command frubulator demo [options].

Here's what I'm going to use for the demo command:

#!/usr/bin/env bash

echo "Hello from $(basename "$0")!"

if [ "$#" = 0 ]; then
  echo "I have been passed no arguments"
else
  echo "My arguments are:"

  while [ -n "$1" ]; do
    echo "  '$1'"
    shift
  done
fi

if [ -n "$MESSAGE" ]; then
  echo "My environment includes \$MESSAGE=$MESSAGE"
else
  echo "My environment does not include \$MESSAGE"
fi

It should be pretty self-explanatory.

Where do we need to look for the executable file?

We could look in a named directory, like $HOME/frobulator/modules/.
Would that do? Maybe.
But it would make everything a lot easier if it was just in your normal $PATH since the subcommand is going to be an executable in its own right. How can we search the path?

Let's use find to check every directory mentioned in the $PATH environment variable for executable files starting with the right prefix. find is pretty cool.

  • We should limit it so it doesn't search recursively by specifying -maxdepth 1
  • We should ignore anything that's not executable with the -executable flag on GNU find and the -perm +111 condition on BSD find. That's a little awkward, but it's not the end of the world. We just figure out which system we're dealing with and put the flag into a variable.
  • Since directories can be executable too, we can filter regular files with -type f.
  • We should tidy up the output so it doesn't include the path or the filename prefix. This is a little trickier to do in the middle of this function so you know what, we'll do it later1.

The $PATH variable contains a list of paths separated by colons (:) but find needs a list of paths separated by spaces2, so we'll use a little variable substitution to swap colons for spaces.

if find --version > /dev/null 2>&1; then
  executables_flag='-executable'
else
  executables_flag='-perm +111'
fi

find ${PATH//:/ } -maxdepth 1 -type f $executables_flag

In shell scripts, functions can only return integers, which are typically used as error states. However, anything sent to stdout (like the output of find...) can be captured in a variable like so:

my_variable=$(find_subcommands)

How can we tell if an executable file exists on the $PATH?

We don't want the command to return an unexpected error message. If we just blithely went ahead and assumed the subcommand was available, then the first time someone mistyped it, and got something like:

$ frobulator demon "hello world"
-bash: frobulator-demon: command not found

Then they'd be confused. Well, maybe not, but it doesn't hurt to intercept this condition and implement our own frobulator-specific error message.

We can use which <program name> to tell if a program is available. If which doesn't find a match, it will return an error, otherwise it will print out all matches. We can suppress that by throwing the output away (into /dev/null).

Note that while BSD's which has a flag for silent operation (-s), that's not available in the GNU version so we'll go with the former approach for portability.

if which "$subcommand" > /dev/null; then
  echo "doing stuff..."
else
  echo "oh noes!"
fi

How can we pass arguments to the subcommand?

If we want to have this all in a nice function, we lose the ability to see the rest of the arguments unless they're passed into the custom function or saved in a global variable. We'll go with the former, even though having a boatload of global variables is pretty standard for shell scripts:

# call_subcommand <parent_command_name> <subcommand> [args...]
call_subcommand() {
  subcommand="$(basename "$1")-$2"
  shift 2
    exec "$subcommand" "$@"
}

How can we pass our environment to the subcommand?

That's easy too. Anything we explicitly export from the parent command is available to the child. We can put that in a handy-dandy function for extra points.

share_environment() {
  export MESSAGE="hello yes I am dog."
}

Putting it all together...

#!/usr/bin/env bash

# find_subcommands <prefix>
find_subcommands() {
  if find --version > /dev/null 2>&1; then
    executables_flag='-executable'
  else
    executables_flag='-perm +111'
  fi

  find ${PATH//:/ } -maxdepth 1 -type f $executables_flag -name "$(basename "$1")-*"
}

# Friendly-print the results of find_subcommands.
list_subcommands() {
  subcommands=$(find_subcommands "$1")

  if [ -n "$subcommands" ]; then
    echo "Available subcommands:"

    for subcommand in $subcommands; do
      echo "  $(basename "$subcommand")" | sed -e "s#$1-##"
    done
  fi
}

# Choose what to export to the subcommand.
share_environment() {
  export MESSAGE="Hello yes I am dog."
}

# call_subcommand <parent_command_name> <subcommand> [args...]
call_subcommand() {
  subcommand="$(basename "$1")-$2"

  if which "$subcommand" > /dev/null; then
    share_environment
    shift 2
    exec "$subcommand" "$@"
  fi

  return 1
}

# Example dispatcher.
case "$1" in
  foo)
    echo "Running internal foo command."
    ;;

  bar)
    echo "Running internal bar command."
    ;;

  *)
    if ! call_subcommand "$0" "$@"; then
      echo "Invalid subcommand."
      echo "Available commands:"
      echo "  foo"
      echo "  bar"
      list_subcommands "$0"
      exit 1
    fi
esac

... and trying it out

$ frobulator foo
Running internal foo command.

$ frobulator help
Invalid subcommand.
Available commands:
  foo
  bar
Available subcommands:
  demo

$ frobulator demo one two three
Hello from frobulator-demo!
My arguments are:
  'one'
  'two'
  'three'
My environment incudes $MESSAGE=Hello yes, this is dog.

Other things you can do

  • Extend the parent program to allow commands of the form <parent command> help <child command> to execute <child command> --help instead.
  • Rewrite the whole thing, only better.

Remember, this doesn't have to be done in shell scripts. You could use the same technique in any command-line app, regardless of what language it's written in. Since this is really just a dispatcher for other commands, you could pick a different langauge each time!

Cover image from pschubert at morguefile.com


  1. I live by this philosophy. 

  2. Technically the internal field separator is user-defined with the shell variable IFS, and we could set it to be a colon for the duration of the find command and then set it back afterwards. I'm trying to keep things simple. 

Extending things (2 Part Series)

1) Extendable heroes 2) Fuzzy branches: a brief example of a git custom command.

Posted on by:

moopet profile

Ben Sinclair

@moopet

I've been a professional C, Perl, PHP and Python developer. I'm an ex-sysadmin. Back in the day, I had a geekcode which I'm not going to share with you. 418 I'm a teapot.

Discussion

markdown guide
 

C.f. also Slack's magic-cli, their implementation of this, and their blog post about it.

 

That's cool. I see they've taken the route of expecting subsequent commands to be in the same directory as the parent command, as opposed to git's "anywhere on your path" version.

That makes it easier to manage at the expense of being less easily customisable for non-privileged users.