Command-line interface design
Recently, I spent quite a bit of time thinking about designing command-line interfaces, for various side-projects of mine. What makes it tricky is that it’s easy for me to spot bad command-line interface design, but it is not as easy to formalize what good command-line interface design should follow.
I ended up finding that — to my (happy) surprise, someone has thought about this and has written Command-Line Interface Guidelines. So, I am going to use this post to walk you through some poorly designed command-line interfaces, along with the reason why they are poorly designed (in my opinion).
Note that while there is a general consensus amongst developers about what a good command-line interface looks like, there are no “hard” rules: some people have different ideas. When I criticise it, I am saying that to me (and probably quite a few other people), this is bad design, and I try to explain why that is, but this does not mean that mine is the only valid opinion.
In general, the traits I care about in command-line interfaces are:
- Predictability. Every command supports
-h,--help, short and long options, has a man page, uses well-structured subcommands if needed, kebab-case for subcommands and options. - Discoverability. If I know roughly what I want to do, I should be able
to quickly discover how to do the thing. That may involve completion with
<Tab>, getting a useful error message when I mistyped an option, showing help text when run with no arguments, a man page that is organized such that I can figure out what I need to do without reading a novel. - Efficiency. Makes good use of screen real-estate (information-dense), quick to type. It does not take 3 seconds to start up because it needs to boot a JVM instance.
- Composability. I can script it. I can parse the output it produces.
- Descriptiveness. If I read an invocation, I should have a rough
idea of what it does. Generally, something like
git submodule update --initreads well,sometool -qlLMs -d 8does not. Sometimes at odds with quick to type, which is why we have both short and long options.
PowerShell ls #
I could probably write a whole essay on why PowerShell is poorly-designed. I will spare you that, and instead focus on two examples.
The PowerShell has a ls command. In the UNIX world, ls is what you use
to see which directories and files are in your current working directory. It
succinctly shows you the contents. If you call it normally, it shows them
in a compact format (as columns, sized to the width of your terminal):
$ ls
Applications Documents Library Music Projects go
Desktop Downloads Movies Pictures Public
If you pipe the output of it into another command, it lists them with one directory per line. Easy to work with and composes with other tools.
It manages to really cram information into the output: for example it can show different types of items in different colors (directories are blue, regular files white, symlinks cyan/magenta). My blog won’t reproduce that, you have to take my word for it. It uses the terminal screen real-estate to the maximum extent allowed by law.
Importantly, it only shows you what you ask for, it is fast to type. You can ask it for more detail on the directories, for example:
$ ls -lh
total 0
drwxr-xr-x@ 4 patrick staff 128B May 28 11:14 Applications
drwx------+ 3 patrick staff 96B Apr 24 17:18 Desktop
drwx------+ 8 patrick staff 256B May 14 02:38 Documents
drwx------@ 111 patrick staff 3.5K May 29 00:11 Downloads
drwx------@ 88 patrick staff 2.8K May 14 02:37 Library
drwx------ 4 patrick staff 128B Apr 25 12:45 Movies
drwx------+ 3 patrick staff 96B Apr 24 17:18 Music
drwx------+ 5 patrick staff 160B May 14 02:37 Pictures
drwxr-xr-x 16 patrick staff 512B May 23 20:41 Projects
drwxr-xr-x+ 4 patrick staff 128B Apr 24 17:18 Public
drwxr-xr-x@ 3 patrick staff 96B Apr 26 10:42 go
This tells me: my home folder has no files (total 0), and for each entry
it shows me a long output (the -l flag) and the sizes in human amounts
(-h flag, means sizes show up as kilobytes, megabytes, etc). If there is
one example of a refined and efficient command-line interface, it would be
ls, at least for me. The amount of thought that has gone into it, most of
which you don’t even notice, is baffling.
Let’s compare this to the ls output on Windows.
PS D:\> ls -Path 'D:\PS\Config\'
Directory: D:\PS\Config
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 07-08-2022 17:34 388 app-1.config
-a---- 16-08-2022 09:28 32 app-1.ps1
-a---- 07-08-2022 17:34 1378 app-2.config
-a---- 07-08-2022 17:34 1378 app-3.config
-a---- 07-08-2022 17:34 388 Get-PI.ps1
PS D:\>
This looks like it was designed by someone who doesn’t use command-line
interfaces a lot. Or it was actually well-designed initially but when presented
to management to get a sign-off, management said: it feels so cramped, can you
find a way to make five entries take up thirteen lines of screen real-estate,
and someone else asked can you think of redundant pieces of information that
we can show in there as well, to show that we don’t care about minimalist
tooling. I am exaggerating a bit, but it does not feel deliberate, the way
that ls is built.
First of all, for no good reason, this command outputs five empty lines: one in the beginning, then the directory (which, for some reason, is indented by four characters), then two empty lines after that, and finally two empty lines at the end. That is poor design, because it wastes space.
Second, it tells you which directory it is listing the files for. That is
redundant: there is a pwd command that tells you this, or if you explicitly
ask for it (like here with -Path), you already know that. One tool should do
one thing, not more and not less.
Next, it draws some kind of table-looking thing. For a tool that you would use
very often (like ls), this is poor design. The question I am trying to answer
is: “what files or directories are here?”. The UNIX ls command gets this right:
it only shows more output when you ask for it.
This is what the Guidelines have to say about this:
The terminal is a world of pure information. You could make an argument that information is the interface—and that, just like with any interface, there’s often too much or too little of it.
A command is saying too little when it hangs for several minutes and the user starts to wonder if it’s broken. A command is saying too much when it dumps pages and pages of debugging output, drowning what’s truly important in an ocean of loose detritus. The end result is the same: a lack of clarity, leaving the user confused and irritated.
It can be very difficult to get this balance right, but it’s absolutely crucial if software is to empower and serve its users.
Interestingly, they do disagree with my opinion here, at least on the default
density of the ls command:
Make the default the right thing for most users. Making things configurable is good, but most users are not going to find the right flag and remember to use it all the time (or alias it). If it’s not the default, you’re making the experience worse for most of your users.
For example, ls has terse default output to optimize for scripts and other historical reasons, but if it were designed today, it would probably default to
ls -lhF.
Finally, the -Path: using uppercase characters in command names or flags is
poor design. You want to optimize these for typing quickly. You don’t really
want to have uppercase characters.
PowerShell command names #
PowerShell comes with commands like this:
Get-Location: Get the current directorySet-Location: Set the current directoryMove-Item: Move a file to a new locationCopy-Item: Copy a file to a new locationRename-Item: Rename an existing fileNew-Item: Create a new file
One positive thing you can say about this is that they are consistent. They
consistently use <verb>-<noun>. How do you set an execution policy? Use
Set-ExecutionPolicy.
While consistency is good, the decision to build it this way sounds like it was made by someone who doesn’t use command-line tools a lot (or, it was a management decision, same thing).
One of the productive ways that you interact with command-line interfaces
is tab-completion. You have a rough idea of what you want to do, but
you don’t know what the exact command is, so you type something that roughly
sounds right, and then hit <Tab> to see what possible completions there are.
For that reason, good command-line interfaces use the <noun>-<verb> pattern.
Because if I am trying to do something, I already know the thing I want to
do something with, but I maybe don’t know what actions I can do with the thing.
Let me give you an example. I am using git, and I want to start using git
submodules. But I don’t know how that works. Or, I know how it works roughly,
but I don’t remember the exact command. What can I do? I can type git submodule,
hit <Tab> and see the actions I can take on a submodule:
$ git submodule
absorbgitdirs add deinit foreach init set-branch
set-url status summary sync update
This makes commands discoverable. And it’s possible because git uses the
<noun>-<verb> pattern: git submodule add, where submodule is the noun,
and add is the verb. git remote add, etc. You get the pattern.
With the PowerShell way of doing things, I don’t have a way (using
tab-completion) to ask a question like “what are all of the things I can do
with an ExecutionPolicy?” All I can ask is “what are all the things I can
Get?” which is not entirely useful.
The Guidelines have this to say:
Use consistent names for multiple levels of subcommand. If a complex piece of software has lots of objects and operations that can be performed on those objects, it is a common pattern to use two levels of subcommand for this, where one is a noun and one is a verb. For example, docker container create. Be consistent with the verbs you use across different types of objects.
Either noun verb or verb noun ordering works, but noun verb seems to be more common.
In my opinion, both work, but noun-verb is more useful in practice.
MSVC options #
MSVC is what Microsoft calls its C/C++ compiler. You generally don’t interact with this one directly, because there are better compilers out there to use (Clang), or if you do, and you’re a Windows person, you’d probably use an IDE that has buttons that you can click that invoke it.
MSVC uses the DOS convention for flags, and no critique of command-line interfaces would be complete if I did not present some arguments for why this is a poor design.
Before I can start, I need to explain something semi-related, about filesystems. Filesystems, as implemented in DOS, were flat. There was no hierarchy. There were no folders. Not because we hadn’t invented that at the time (UNIX had hierarchical filesystems from the start), but because the people who implemented it just didn’t implement it. Time constraint or a skill issue.
Filesystems are hierarchical. They form a tree. You have a folder, and the folder has things in it. And those things can be folders as well. You get the idea, but here is an illustration:
project
┌────────────┼─────────────┐
│ │ │
content static hugo.toml
┌───┴───┐ │
posts about.md css
You may notice that project sits above content and static. It’s
a hierarchy: the root sits at the top, everything comes down. It’s also
called a parent-child relationship, where the top is the parent, and
what (immediately) follows under it are the children.
For that reason, we use the slash / character to encode paths. It has a
semantic meaning: it tilts forward. A path like project/static/css encodes
“project is above static” and “static is above css”. The parent-child
relationship is encoded in what lives above what. Like a family tree. This is
similar to mathematics, where you might write a fraction like:
14
──
28
As “14/28”. Makes sense? Cool. That is why we also show file trees like this sometimes:
project/
├── content/
│ ├── posts/
│ │ ├── 2026/
│ │ │ ├── command-line-interface-design.md
│ │ │ └── typography-and-unicode.md
│ │ └── index.md
│ └── about.md
├── static/
│ ├── css/
│ │ └── style.css
│ └── images/
│ └── logo.svg
├── hugo.toml
└── README.md
This notation now makes sense. It shows the same relationship (project at the
top, content, static, hugo.toml, README.md as children). We show the /
character to denote “this is a folder, it has things under it”. Makes sense, so
far.
Now, DOS, for reasons that people understand but in retrospect make very little
sense, had different ideas. They thought: instead of options like -a and --help,
why don’t we use / for options? This is why MSVC ended up with options like this:
| Option | Description |
|---|---|
/analyze | Enables code analysis. |
/clr | Produces an output file to run on the common language runtime. |
/clr:implicitKeepAlive- | Turn off implicit emission of System::GC::KeepAlive(this). |
/clr:initialAppDomain | Enable initial AppDomain behavior of Visual C++ 2002. |
/constexpr:backtrace<N> | Show N constexpr evaluations in diagnostics (default: 10). |
/constexpr:depth<N> | Recursion depth limit for constexpr evaluation (default: 512). |
/EHa | Enable C++ exception handling (with SEH exceptions). |
/EHc | extern “C” defaults to nothrow. |
/EHr | Always generate noexcept runtime termination checks. |
/EHs | Enable C++ exception handling (no SEH exceptions). |
/FC | Displays the full path of source code files passed to cl.exe in diagnostic text. |
/Fd | Renames program database file. |
/Fe | Renames the executable file. |
/Fo | Creates an object file. |
/Fp | Specifies a precompiled header file name. |
/fpcvt:BC | Backward-compatible floating-point to unsigned integer conversions. |
/fpcvt:IA | Intel native floating-point to unsigned integer conversion behavior. |
/GL[-] | Enables whole program optimization. |
/Gm[-] | Deprecated. Enables minimal rebuild. |
/GR[-] | Enables run-time type information (RTTI). |
/MD | Compiles to create a multithreaded DLL, by using MSVCRT.lib. |
/MDd | Compiles to create a debug multithreaded DLL, by using MSVCRTD.lib. |
/Qsafe_fp_loads | Uses integer move instructions for floating-point values and disables certain floating point load optimizations. |
/Wv:xx[.yy[.zzzzz]] | Disable warnings introduced after the specified version of the compiler. |
What they managed to do was to get the worst of both worlds. The options all
look like they should be paths, and there is no coherent convention among them:
some are readable words (/analyze), but most are cryptic letter-soup (/EHa,
/Fo, /MDd), and the casing is significant — /c (compile only) and /C
(keep comments while preprocessing) are two completely different flags. They are
not clean long options, the way the Guidelines recommend, and
they are not short options either: you can’t combine several of them into one
(the way /a /b /c would collapse into /abc if these were real short options).
And, in another hilarious decision, because they decided to use the forward slashes
that semantically encode a hierarchical relationship in paths for options, when they
did implement hierarchical filesystems, they couldn’t use / to encode paths. Instead,
they ended up using backwards slashes (\), so paths end up being expressed like
project\static\css.
This is semantically incorrect. Because backwards slashes are tilted backwards, semantically they imply that the preceding item sits lower in the hierarchy than the subsequent one. Which means, technically, if you wanted to output a file tree in Windows, the “correct” way to do it would be to do this:
┌── command-line-interface-design.md
├── typography-and-unicode.md
┌── 2026\
├── index.md
┌── posts\
├── about.md
┌── content\
│ ┌── style.css
│ ┌── css\
│ │ ┌── logo.svg
│ ├── images\
├── static\
├── hugo.toml
├── README.md
project\
In another hilarious twist to this, the backwards slash character is in the
ASCII region of characters that have a different meaning depending on the
codepage. In the early days of computing, there was no Unicode. Instead, there
were codepages, where individual characters would be rendered differently
depending on what region you were in. The character that represents a backslash
(0x5c) means different things in different regions. It was used to house the
currency symbol in Japanese and Korean codepages. Which means that,
unintentionally, if people from those regions used Windows, their paths would
be displayed as project¥static¥css. When Unicode was introduced, Microsoft
had a chance to fix this. Instead, they opted to special-case it so that
Unicode fonts on Korean and Japanese systems rendered backslashes as their
currency symbol to preserve the (strange)
behaviour.
Enterprise software is a goldmine if you like comedy. I don’t know if this is
still true today.
I think this is a wildly interesting showcase of how one bad design decision is the reason an entire lineage of a very common operating system uses the wrong character for filesystem paths (a semantically incorrect backwards slash, or even a random currency symbol). At the time, they maybe didn’t know better, or they didn’t think it through. Maybe they didn’t think hierarchical filesystems would ever matter, just like how they thought the iPhone and the iPad would never become big.
Hugo #
Hugo is a static-site generator. It happens to be the one that I use for this very blog. It’s a great tool: it’s widely used, there’s lots of themes for it. It has an active community around it. I don’t have much bad to say about it, apart from the fact that the command-line options it uses look like this:
$ hugo --help
[..]
Flags:
-b, --baseURL string hostname (and path) to the root, e.g. https://spf13.com/
-D, --buildDrafts include content marked as draft
-E, --buildExpired include expired content
-F, --buildFuture include content with publishdate in the future
--cacheDir string filesystem path to cache directory
--cleanDestinationDir remove files from destination not found in static directories
--clock string set the clock used by Hugo, e.g. --clock 2021-11-06T22:30:00.00+09:00
--config string config file (default is hugo.yaml|json|toml)
--configDir string config dir (default "config")
-c, --contentDir string filesystem path to content directory
-d, --destination string filesystem path to write files to
[..]
I don’t want to write --buildDrafts. I want to write --build-drafts. Uppercase
characters in command-line flags are just bad design. It looks strange, it is awkward
to type (for me, anyways). It violates my Predictability principle.
The Guidelines make no recommendation about the casing of options, they only write this:
Flags are named parameters, denoted with either a hyphen and a single-letter name (
-r) or a double hyphen and a multiple-letter name (--recursive). They may or may not also include a user-specified value (--file foo.txt, or--file=foo.txt). The order of flags, generally speaking, does not affect program semantics.
Alloy #
Another difficult one is Alloy. It’s a tool that is part of the Grafana stack. Now, to give it some credit: Alloy is not really a tool that you use interactively, it’s more of a backend service type of thing. However, they use a pattern that I find particularly distasteful. Let me show you an excerpt of their flags:
$ alloy run --help
[..]
Usage:
/bin/alloy run [flags] path
Flags:
--cluster.advertise-address string Address to advertise to the cluster
--cluster.advertise-interfaces strings List of interfaces used to infer an address to advertise (default [eth0,en0])
--cluster.discover-peers string List of key-value tuples for discovering peers
--cluster.enable-tls Specifies whether TLS should be used for communication between peers
--cluster.enabled Start in clustered mode
--cluster.join-addresses string Comma-separated list of addresses to join the cluster at
--cluster.max-join-peers int Number of peers to join from the discovered set (default 5)
--cluster.name string The name of the cluster to join
--cluster.node-name string The name to use for this node
--cluster.rejoin-interval duration How often to rejoin the list of peers (default 1m0s)
--cluster.tls-ca-path string Path to the CA certificate file
--cluster.tls-cert-path string Path to the certificate file
--cluster.tls-key-path string Path to the key file
[..]
A good command-line design would be --cluster-advertise-address: using
kebab-case, not dots, to me.
git-lfs help #
Help output usually comes in two shapes: a short help output (which you get when
you call the command with no arguments, or with -h), and long help output
(which you get by running --help). The difference is that the short help output
should be optimized to be concise: one small sentence per subcommand or flag is
sufficient. This is the one I reach for when I know how the tool works, and I just
forgot the name of a flag or subcommand. The long help output can include more
information (which means you may need to scroll to read it).
git-lfs gets this pretty wrong in my opinion. I can’t reproduce the full short
help output, because it’s too long.
$ git lfs
[..]
View or add Git LFS paths to Git attributes.
git lfs uninstall:
Uninstall Git LFS by removing hooks and smudge/clean filter configuration.
git lfs unlock:
Remove "locked" setting for a file on the Git LFS server.
git lfs untrack:
Remove Git LFS paths from Git Attributes.
git lfs update:
Update Git hooks for the current Git repository.
git lfs version:
Report the version number.
Low level plumbing commands
~~~~~~~~~~~~~~~~~~~~~~~~~~~
git lfs clean:
Git clean filter that converts large files to pointers.
git lfs filter-process:
Git process filter that converts between large files and pointers.
git lfs merge-driver:
Merge text-based LFS files
git lfs pointer:
Build and compare pointers.
git lfs post-checkout:
Git post-checkout hook implementation.
git lfs post-commit:
Git post-commit hook implementation.
git lfs post-merge:
Git post-merge hook implementation.
git lfs pre-push:
Git pre-push hook implementation.
git lfs smudge:
Git smudge filter that converts pointer in blobs to the actual content.
git lfs standalone-file:
Git LFS standalone transfer adapter for file URLs (local paths).
Examples
--------
To get started with Git LFS, the following commands can be used.
. Setup Git LFS on your system. You only have to do this once per user
account:
+
git lfs install
. Choose the type of files you want to track, for examples all ISO
images, with git lfs track:
+
git lfs track "*.iso"
. The above stores this information in gitattributes(5) files, so that
file needs to be added to the repository:
+
git add .gitattributes
. Commit, push and work with the files normally:
+
git add file.iso
git commit -m "Add disk image"
git push
It outputs some raw ASCIIdoc (is that ASCIIdoc?), you can notice those strange
stray dots and plusses. It is not concise at all. If I want to be able to read this,
I need to pipe it into less, or scroll up.
This is what the Guidelines have to say about help text output:
Display extensive help text when asked. Display help when passed -h or –help flags. This also applies to subcommands which might have their own help text.
Display concise help text by default. When myapp or myapp subcommand requires arguments to function, and is run with no arguments, display concise help text.
You can ignore this guideline if your program is interactive by default (e.g. npm init).
The concise help text should only include:
- A description of what your program does.
- One or two example invocations.
- Descriptions of flags, unless there are lots of them.
- An instruction to pass the –help flag for more information.
For reference, I have built an implementation of git-lfs in Rust, this is my
short help output:
$ git lfs
Git LFS — large file storage for git
Usage: git-lfs [COMMAND]
Commands:
clean Git clean filter that converts large files to pointers
smudge Git smudge filter that converts pointer in blobs to the actual content
install Install Git LFS configuration
uninstall Remove Git LFS configuration
track View or add Git LFS paths to Git attributes
untrack Remove Git LFS paths from Git attributes
filter-process Git filter process that converts between pointer and actual content
fetch Download all Git LFS files for a given ref
pull Download all Git LFS files for current ref and checkout
push Push queued large files to the Git LFS endpoint
clone Efficiently clone a LFS-enabled repository
post-checkout Git post-checkout hook implementation
post-commit Git post-commit hook implementation
post-merge Git post-merge hook implementation
pre-push Git pre-push hook implementation
version Print the git-lfs version banner and exit
pointer Build, compare, and check pointers
env Display the Git LFS environment
ext List the configured LFS pointer extensions
update Update Git hooks
migrate Migrate history to or from Git LFS
checkout Populate working copy with real content from Git LFS files
prune Delete old LFS files from local storage
fsck Check Git LFS files for consistency
status Show the status of Git LFS files in the working tree
lock Set a file as "locked" on the Git LFS server
locks Lists currently locked files from the Git LFS server
unlock Remove "locked" setting for a file on the Git LFS server
ls-files Show information about Git LFS files in the index and working tree
logs Show errors logged by Git LFS
merge-driver Merge driver for LFS-tracked files
help Print this message or the help of the given subcommand(s)
Options:
-V, --version Print the version banner and exit
-h, --help Print help (see more with '--help')
It’s short, it tells you exactly what you need to know (every subcommand and a short description, designed so that it fits onto a single terminal view and you don’t need to scroll).
Go compiler #
Some historical tools do not follow good command-line interface design principles, and while that is annoying, it is understandable (breaking backward compatibility is usually not something you want to do).
However, the Go compiler is not an old tool. It was released in 2009, which is reasonably modern. And yet, the Go compiler (which, if you are a software engineer, is a tool you’d interact with a lot) gets command-line options wrong.
$ go help build
[..]
-C dir
Change to dir before running the command.
Any files named on the command line are interpreted after
changing directories.
If used, this flag must be the first one in the command line.
-a
force rebuilding of packages that are already up-to-date.
-n
print the commands but do not run them.
-p n
the number of programs, such as build commands or
test binaries, that can be run in parallel.
The default is GOMAXPROCS, normally the number of CPUs available.
-race
enable data race detection.
Supported only on darwin/amd64, darwin/arm64, freebsd/amd64, linux/amd64,
linux/arm64 (only for 48-bit VMA), linux/ppc64le, linux/riscv64 and
windows/amd64.
-msan
enable interoperation with memory sanitizer.
Supported only on linux/amd64, linux/arm64, linux/loong64, freebsd/amd64
and only with Clang/LLVM as the host C compiler.
PIE build mode will be used on all platforms except linux/amd64.
-asan
enable interoperation with address sanitizer.
Supported only on linux/arm64, linux/amd64, linux/loong64.
Supported on linux/amd64 or linux/arm64 and only with GCC 7 and higher
or Clang/LLVM 9 and higher.
And supported on linux/loong64 only with Clang/LLVM 16 and higher.
-cover
enable code coverage instrumentation.
[..]
By convention, the single-dash syntax is for single-character flags. And this is by design: often-used flags can have short options (single-character), so that you can combine them. For example, these are equivalent:
ls -lah
ls --long --all --human
You can choose between conciseness (fast to type for common flags), and clarity (typing out the long flags, with double-dashes).
Go, for some reason, uses the short option syntax for all flags. Which means that you can’t combine them (to type them quickly), this would be invalid:
$ go build -anp 2
This is something that would annoy me a lot if I had to interact with the Go compiler on a daily basis. And the other problem is: because they didn’t get this right from the beginning, they are now kind of stuck with this: if Go fixed the command-line options, all tooling that is built on top of Go (IDEs that call it) would break. This goes to show that taking care to design things well from the start is quite important, especially for command-line tools that tend to be composed.
Go is a particular example because not only does it get this wrong, but it encodes the wrong behaviour in the flag package. I don’t know why that is. Maybe they were too lazy to properly implement short and long options? Or maybe they did not care?
Java #
I don’t think I need to explain a lot, if I just show you some invocations:
$ java -Xmx512m -XX:+UseG1GC
If you read the man page for java, you will see a best-practises document
on how not to design a command-line interface. It has a weird mix of option
styles, for example:
java -jar(can’t decide if it wants to be a subcommand,java jar, or an option,java --jar)- single-dash long options:
-agentlib:libname[=options] - double-dash long options:
--enable-preview - single-dash long options with a single-dash short option that is still multiple characters:
-disableassertionsor-da - duplicated:
--versionand-version -XX:AllocateHeapAt=path(I don’t even know how to categorize-XX)
I think in Java’s defense, Java is quite old, and a lot of this is historical baggage that grew over time and couldn’t easily be fixed, because tooling depends on this. But the end-result is that these options are very cryptic. Unless you are familiar with Java, it’s quite difficult to read these and understand what their intent is.
If I had to design the java command-line interface, I would follow standards
(short/long options split), and either turn jar into a subcommand, or have it
recognize the kind of file (class versus jar) and do the right thing
automatically. The -X<name> for extra arguments is not terrible, but for
common commands I’d probably add a first-class syntax. Given the fact that
Java’s idea of memory management is still to pre-allocate all memory it can
ever use at startup, --memory 512MB would be a useful candidate.
GCC and Clang #
This is the point where I would love to say “other compilers got this right”,
but that is not quite true. The gcc --help output is around 1800 lines long,
most of which is documenting the various options. And those come in a colorful
mix-and-match of short flags (-a), long flags (--long), long flags with a
single dash (-trigraph), some -Xname flags. Clang does the same thing,
although I believe that most of this is for compatibility with gcc, and
not because they chose to design their interface like that.
One particular gripe I have is the compiler driver’s pass-through-to-the-linker
syntax. The way you tell GCC to pass flags to the linker (-rpath, --as-needed)
is by writing -Wl,<args>, where args are comma-separated. You end up with
abominations like this:
gcc main.c -Wl,-rpath,/usr/local/lib -Wl,--as-needed -Wl,-soname,libfoo.so.1
So, it has a syntax for encoding command-line options passed to the linker
(comma-separated). That’s already bad, but what makes it worse is that -W is
overloaded into nonsense. The -W flag means warning everywhere else
(-Wall means warn on everything, -Wextra means warn on even more
things, roughly speaking).
But there is one meta-point here:
- GCC was released in 1987. If any tool has an “excuse”, it is GCC.
- Clang is mostly drop-in compatible with GCC, so they can’t really deviate much.
- Java was released in 1996. They could have reasonably designed it better.
- Go has no excuse at all.
OpenSSL #
OpenSSL is not exactly famous for its user-friendly command-line interface. I suppose that is okay, because you don’t exactly interact with it daily. I certainly cannot use it without searching for how do I do X with OpenSSL. Here are some gems:
# underscore in subcommand
openssl s_time
# single-dash options
-ssl3, -tls1, -tls1_1, -tls1_2, -tls1_3, -no_ssl3, -no_tls1, -no_tls1_1, -no_tls1_2, -no_tls1_3
# whatever this is
-key pkcs11:object=some-private-key;pin-value=1234
I am not too mad at OpenSSL, because the purpose of it is to have a good
implementation of cryptographic primitives and protocols — not to have the
most user-friendly command-line. If I had to improve it, I would get
rid of the single-dash options, use kebeb-case. But this is a command
that I reach for so rarely, and whenever I do, I am okay with looking it
up.
GPG #
GPG is an interesting one to mention, and for two reasons. First, it is a tool
that is kind of widely used (usually not directly, for example DNF and APT use
it behind the scenes), I have used it many times, and yet every time I need to
use it, I need to do an internet search to figure out how to do the thing I
need to do. Second, it falls into a trap that a few other tools also fall into
(like ssh-keygen, which I will talk about). GPG lets you do different
things, but where most tools model the different things as subcommands
(like git commit, docker ps), GPG uses flags.
If you encrypt something, you’d use the --encrypt flag. That should have
been the subcommand! But it’s not, so you end up with:
gpg --encrypt --armor --recipient alice file.txt
This means I can’t run gpg and use <Tab> to figure out all of the things
that it can do, like I can with git, or docker, or cargo.
I also can’t run gpg encrypt and get the help text for that subcommand, showing
me all relevant options. Instead, I have to read the man page, which is 5000
lines long when rendered with 80 columns, and figure out which of the options
applies to the action I want to take. Yay.
For a whole category of operations, GPG abandons flags altogether. If you want to change a key’s expiry, add a user ID, or sign someone else’s key, you do not pass options — you are dropped into an interactive sub-shell:
$ gpg --edit-key alice
gpg> expire
gpg> trust
gpg> save
This is both undiscoverable — you have to already know that expire exists,
it is not in any --help you would think to run — and thoroughly unscriptable.
GPG itself clearly knows this is a problem, because it later grew a set of
non-interactive escape hatches like --quick-set-expire and --quick-add-uid.
When a tool sprouts a parallel “quick” interface specifically to avoid its own
primary one, that is a smell you can detect from across the room.
The output that GPG produces is also not great, often serving neither humans nor machines well. But that’s a whole nother can of worms that I won’t get into.
GPG has a lot of footguns. It is neither intuitive, self-descriptive, nor discoverable. In my opinion, GPG is a case-study of how not to design tools. And the fact that it is still used, despite trying so hard to keep people from using it, attests to the fact that it is useful. Okay, well.. the whole web-of-trust thing that PGP pioneered and GPG implements, nobody uses that. We have become soyboys who eat TOFU. But we do use it to sign stuff. And encrypt stuff, sometimes.
What I mentioned earlier: ssh-keygen has the same issue. It started out as a
tool to produce SSH keys. But then it gained more features, for example you can
use it to sign data, and to verify signatures (using -Y sign). In fact, it
can do so many things that its synopsis is 26 lines long. It’s an artefact of a
tool that grew over time. If I had to design it from scratch, it would use
subcommands (or some functionality would be put into other tools, so
ssh-keygen just does one thing, which is generate keys). Interestingly, I
have signed more things with ssh-keygen than I have with gpg.
Historical baggage #
Some UNIX commands are old enough that they pre-date many of the established conventions. Specifically, the handling of options (with short and long options) is something that the GNU people really standardized (I believe, anyways, I am not a historian). That means that there are still tools in use that use other patterns, for example:
dduses options without any dashes (egdd if=abc of=definstead ofdd -i abc -o def)finduses single-dash options, and has a bit of a special syntax, egfind . -name *.c -exec wc -l {} \;pssupports both BSD style (ps aux, no dash), System V style (ps -ef) and GNU long style (ps --forest). Kind of the worst of all options: supporting multiple different modes.
I want to say that these are so bad that I’ve adopted other tools, but I
haven’t. I use ps aux and find a lot. You get used to them. Would I build
tools like that? No. But their documentation is great, their output is concise,
and besides the shape of the options, they don’t get in your way.
Accidental versus essential complexity #
The next two tools that I will talk about are a bit different. Although you use them like regular commands with options (that happen to be ill-designed, they make the same single-dash mistake that we have covered before), the options they have are not independent: instead, they express a sequence of transformations on some input.
The Guidelines state:
The order of flags, generally speaking, does not affect program semantics.
But there are cases where you can’t do this. Brooks, in his paper No Silver Bullet, introduces the terminology of accidental complexity, which is complexity you introduce yourself by designing something poorly, and essential complexity, which is inherent to the problem you are solving.
There is no good reason for Hugo to use --buildDrafts instead of --build-drafts,
other than maybe that they did not know any better. But there is a valid
reason for the following tools to have options that are not order-independent.
An argument can be made that the following two tools have command-line interfaces that don’t follow the conventions because they are trying to express something through command-line flags that is inherently complex and not independent (these are not flags that set individual options, but a sequence of transformations). For each, I will argue what I think is wrong with these interfaces, and how I would solve them. But the important bit is that these are just fundamentally tricky to solve, so no solution will be perfect.
ffmpeg #
ffmpeg’s options are not independent: where you place an option, relative to the inputs and outputs, changes what it means. The structure is roughly:
ffmpeg [global options] {[input options] -i INPUT}... {[output options] OUTPUT}...
So an option that appears before an -i applies to that input, and the same
option after the -i applies to the output. The classic example is -ss
(seek to a timestamp):
# input seeking: fast, seeks before decoding
ffmpeg -ss 60 -i input.mp4 -t 10 out.mp4
# output seeking: slow, decodes from 0 and throws frames away
ffmpeg -i input.mp4 -ss 60 -t 10 out.mp4
Same flag, same value. The first one jumps close to the 60-second mark before it even starts decoding (fast); the second one decodes the whole file from the start and discards everything before 60 seconds (slow).
The reason the options aren’t independent is that you are not configuring a single operation — you are describing a pipeline: take these inputs, run them through these filters, encode them like this, write them to these outputs. That is a graph, and a flat list of independent flags simply cannot express a graph. The complexity is essential.
What I would argue is that the mistake is trying to express a pipeline through
argv at all. And ffmpeg sort of agrees with me, because it already grew its own
filter language, which they call filtergraphs:
ffmpeg -i in.mp4 -vf "scale=1280:720,hflip" out.mp4
If I had to design this interface, I would first fix the flags (use proper
-i, --input, get rid of all single-dash long flags). And instead of trying
to squeeze the definition of a pipeline through command-line arguments, I would
come up with a proper language to define these, and have the command-line tool
take a file as input.
I don’t know what shape the filter language would take, but something like this would come to mind. I am sure that there are better designs for it:
input.scale(1280, 720).hflip().skip(60s)
You would then invoke ffmpeg like this:
ffmpeg --input in.mp4 --filter filter.ff --output out.mp4
It means you can use a proper language to define filters, you don’t have to do awkward command-line parsing, your command-line parsing becomes much simpler. It does mean that even for one-off invocations you need to write a filter file, but it also recognizes that defining filter graphs has inherent complexity, which is best done in a proper way (using a language) instead of “squeezed” through command-line arguments.
ImageMagick convert #
ImageMagick has the same essential complexity as ffmpeg, just for images instead
of audio and video. convert (renamed to magick in version 7) processes its
arguments left-to-right, like a tiny stack machine: each operator transforms the
image as it currently is, and the next operator picks up where the previous one
left off. The argument list is the program.
# shrink first, then blur the small image
magick in.png -resize 50% -blur 0x8 out.png
# blur the full-size image first, then shrink
magick in.png -blur 0x8 -resize 50% out.png
Both are valid, and they produce different images for different amounts of work (blurring at full resolution is more expensive). As with ffmpeg, this is essential: you’re describing a pipeline, and a pipeline has an order. The guideline I quoted earlier — that the order of flags shouldn’t affect semantics — is exactly what both of these tools break, and for once I think they have a decent excuse.
Where ImageMagick does pile accidental complexity on top is in two places.
First, some flags are “settings” rather than “operators”: instead of
transforming the image, they change how later operators behave — and you can’t
tell which is which just by looking. The famous one is -density, when
converting a PDF or SVG:
# rasterises the PDF at 300 DPI — sharp
magick -density 300 in.pdf out.png
# rasterises at the default 72 DPI, then sets a metadata field — blurry
magick in.pdf -density 300 out.png
If you put the setting after the input, it silently does nothing useful. There is no error; you just get a low-resolution image and a confusing afternoon.
Second, there’s the +/- convention. Most operators come in a minus form and
a plus form, and you’d reasonably assume - means “on” and + means “off”.
Sometimes that’s true (-antialias/+antialias), but sometimes it isn’t:
-append stacks images vertically, while +append stacks them horizontally.
So the sign isn’t really a boolean — it’s just another character to memorise.
I would argue that when you get to the place where you need to invent syntax,
like the + flag prefixes, you should maybe realize that the operations you’re
trying to encode are not a good fit for command-line syntax. On the other hand,
this may just be a case where that complexity is actually useful.
Conclusion #
This article covered a lot — more than I intended to cover, actually. I wanted to write this just to point out some command-line interfaces that annoy me. But then it ended up turning into a bit of a deep-dive into tools.
Looking back at the tools I’ve picked on, not all bad design is equally bad. They fall into roughly three buckets.
Many have no excuse. Go was released in 2009, Hugo and Alloy are younger still, and PowerShell was a clean-slate design. They had every convention available to them and chose not to follow it. This is the frustrating category, precisely because it was avoidable.
Then there are the tools that are simply old. dd, find, ps, and GCC
predate the conventions they “violate”, and you can’t break backwards
compatibility on a tool that half the world’s scripts depend on. They’re poorly
designed by today’s standards, but the design was defensible at the time, and
we’ve adapted. I still type ps aux and find . -name without thinking. The
lesson isn’t “these tools are bad” so much as “get it right early, because
you’ll be stuck with it” — which is the same lesson as the first bucket, seen
from the other end.
Finally, a couple of tools — ffmpeg and ImageMagick — aren’t poorly designed so much as hard. They’re trying to express a pipeline, a pipeline is a graph, and a flat list of flags can’t capture a graph. That’s essential complexity in Brooks’ sense: it lives in the problem, not in the solution. The best you can do is what they both eventually did: grow a real language for it (and even then it won’t be clean).
If you design command-line tools today, try to avoid these traps. You can build interfaces that people will love, if you do the following:
- Follow the Command-Line Interface Guidelines, if you can.
- Design your command-interface right, from the start. Treat it as an external API: getting it wrong is expensive, people will build stuff on it.
- Use a proper command-line parsing library that supports proper short and long commands. clap is excellent, argparse works well. Your language of choice should have a library to help you. Make sure the library gets it right, use a different one if it does not follow the conventions.
- Structure your tool into subcommands, if that is appropriate. That makes the tool much easier to use.
- If your tool does something that you can’t neatly express in terms of
command-line flags or subcommands, consider using a proper language, and load
the pipeline, config or whatever from a file, rather than trying to squeeze it
into command-line options like
ffmpegormagickdo.
I also have to note that, even the tools I call out as being “bad” — I still use them, and I am grateful that they exist. It can be frustrating when something is not well-designed, but I’d rather have imperfect tools than no tools at all. Especially when tools are written by people in their spare time.