Hessra Logo
BACK TO BLOG
#cli#devtools#ux#dx#hessra-cli

Designing a CLI That Doesn't Get in Your Way

Design principles for building command-line tools that minimize friction through smart defaults, persistent config, and dual usage modes for humans and automation.

by Jacob Valentic

Designing a CLI That Doesn't Get in Your Way

I just finished the first real release of the Hessra CLI, and along the way I learned something important: the best CLI tools are the ones you forget you're using.

The CLI is built for managing Hessra's mTLS-backed identity tokens and capability tokens. I originally made it for myself - to handle long-lived identity tokens, delegate them to sub-identities offline, and exchange them for single-use, single-scope authorization tokens for testing or CI/CD jobs. But as I built it, I kept running into the same friction points that plague most CLI tools.

This post walks through the design principles I followed to build a CLI that gets out of your way, with concrete examples from hessra-cli and general guidance for anyone building command-line tools.

Start by Imagining How the Tool Will Be Used

Before writing any code, I thought about the different ways I wanted to use this tool:

As a human developer, I'd run commands interactively dozens of times per day. I wanted:

  • Minimal typing for repeated operations
  • Helpful feedback and progress indicators
  • The tool to remember my context (which server, which tokens)
  • Clear guidance when something went wrong

In automation (scripts, CI/CD, Dockerfiles), I needed:

  • Explicit arguments that make dependencies obvious
  • Predictable behavior with no interactive prompts
  • Machine-readable output for parsing
  • Commands that fail loudly when something's missing

This led to a key insight: the same command should support both usage patterns. A wizard-style setup with stored configuration for humans, while preserving always-required-args commands for programmatic use where it's important to see exactly how the tool is being invoked.

The Human Problem: Too Many Flags, Too Much Friction

Here's what a typical authentication might look like with a naive CLI design when you're using it directly:

hessra identity authenticate \
  --server test.hessra.net \
  --port 443 \
  --cert ~/.hessra/client.crt \
  --key ~/.hessra/client.key \
  --ca ~/.hessra/ca.pem \
  --save-as default

Six flags for a single operation. Now imagine running this 10 times a day during development. You'd either create a shell alias or give up and hardcode credentials somewhere.

The goal became clear: make the first-time experience guided and educational, but make the tenth-time experience nearly effortless.

Design Principle 1: Minimize Required Arguments Through Smart Defaults

The core idea is simple: the CLI should remember context so you don't have to.

In hessra-cli, we achieve this through persistent, server-aware configuration:

# First time: interactive wizard that teaches you the tool
hessra init

# The CLI asks for your server hostname, fetches CA certs automatically,
# caches the public key, and sets up a default server context

After running init once, subsequent commands become dramatically simpler. Compare the before/after for common workflows:

# Before configuration - every command needs server and CA
hessra identity authenticate \
  --server test.hessra.net \
  --cert ~/path/to/client.crt \
  --key ~/path/to/client.key \
  --ca ~/.hessra/ca.pem \
  --save-as default

hessra identity delegate \
  --server test.hessra.net \
  --ca ~/.hessra/ca.pem \
  --identity "hessra:jake:agent"

# After configuration - server and CA are remembered
hessra identity authenticate \
  --cert ~/path/to/client.crt \
  --key ~/path/to/client.key

hessra identity delegate \
  --identity "hessra:jake:agent"

The CLI now:

  • Uses your default server automatically
  • Has the CA cert cached from init
  • Fetches and caches the server's public key for offline operations

Note that cert/key paths aren't stored, you still provide those for each authentication. In the future, we'll add in OAuth2/OIDC/SSO to make the human authentication path even easier. But for now, this keeps secrets out of config files while eliminating the repetitive server and CA arguments.

This works because configuration is stored per-server in ~/.hessra/servers/<hostname>/, with a global config pointing to your default. When you switch projects or environments, you just hessra config switch yourco-prod.hessra.net and all subsequent commands target the right server.

Design Principle 2: Progressive Disclosure of Complexity

Not everyone needs all the features all the time. The CLI should guide beginners while staying out of the way for experts.

The init command demonstrates this well:

# Interactive mode (for learning) - shows current state, explains actions
hessra init

# Direct mode (for automation) - explicit, scriptable
hessra config init test.hessra.net --set-default --skip-fetch

The interactive mode teaches you what flags exist. The direct mode is perfect for Dockerfiles where you want to see exactly what's happening.

Design Principle 3: Configuration Hierarchy & Intelligent Fallbacks

A good CLI should respect multiple sources of configuration and do the right thing without being told, but allow overrides when needed. The precedence should be:

  1. CLI arguments (explicit user intent - highest priority)
  2. Environment variables (good for CI/CD or temporary overrides)
  3. Config file (persistent preferences)
  4. Automatic fetching (try to help)
  5. Helpful error (with actionable next steps - lowest priority)

Here's how this plays out in practice. For the server parameter:

let server = if let Some(s) = server_arg {
    s  // CLI arg wins
} else if let Some(env) = env::var("HESSRA_SERVER").ok() {
    env  // Env var is next
} else if let Some(default) = config.default_server {
    default  // Config file default
} else {
    return Err("No default server set. Run: hessra config switch <server>")
};

For the CA certificate, we go even further with automatic fetching:

let ca = if let Some(provided_ca_path) = ca_path {
    fs::read_to_string(&provided_ca_path)?  // CLI arg
} else if server_ca_path.exists() {
    fs::read_to_string(&server_ca_path)?  // Cached from config
} else {
    match hessra_sdk::fetch_ca_cert(&server, Some(port)).await {
        Ok(ca_cert) => {
            fs::write(&server_ca_path, &ca_cert)?;  // Fetch and cache
            ca_cert
        }
        Err(e) => {
            return Err(format!(
                "CA certificate not found and auto-fetch failed.\n\
                Run: hessra init {server}\n\
                Or: provide --ca <path>"
            ))
        }
    }
};

This means:

  • First run? The CA gets fetched and cached automatically
  • Cert rotated? Provide --ca to override
  • CI/CD? Use HESSRA_SERVER environment variable
  • Offline development? Use the cached cert from last time
  • Something wrong? Get actionable next steps

Design Principle 4: Dual Output Modes

CLIs need to serve two masters: humans who want color and context, and scripts that need parseable data.

Human-friendly output (default):

$ hessra identity authenticate
✓ Authentication successful!
  Server: test.hessra.net
  Identity: uri:urn:test:user
  Expires in: 7200 seconds
  Token saved as: default

Machine-readable output (with --json):

$ hessra identity authenticate --json
{
  "success": true,
  "server": "test.hessra.net",
  "identity": "uri:urn:test:user",
  "expires_in": 7200,
  "token_saved_as": "default",
  "token_path": "/home/user/.hessra/servers/test.hessra.net/tokens/default.token"
}

Pipe-friendly output (with --token-only):

$ hessra authorize request --resource db1 --operation read --token-only
EtQBCmEKHwoJcmVzb3VyY2UxEgRyZWFkGhEKDwoNdXNlcjp0ZXN0dXNl...

The key insight: never make the user parse human-friendly output. If your CLI prints "Success! Token: abc123", someone will pipe it through awk or sed to extract the token, and it will break when you add helpful context.

Provide explicit modes:

  • Default: optimized for human reading (colors, structure, context)
  • --json: structured data for jq/parsing
  • --token-only: just the essential output, nothing else

Design Principle 5: Discoverable Commands

Command structure should be intuitive and self-documenting. We use a clear noun-verb hierarchy:

hessra identity authenticate    # noun: identity, verb: authenticate
hessra identity delegate        # noun: identity, verb: delegate
hessra authorize request       # noun: authorize, verb: request
hessra config init            # noun: config, verb: init

This makes commands discoverable - if you know there's an identity command, you can guess there might be identity list or identity delete.

Design Principle 6: Error Messages That Teach

When something goes wrong, don't just say what failed. Explain how to fix it.

Bad error:

Error: Server not found

Good error:

Server 'prod.hessra.net' not configured.

Run: hessra init prod.hessra.net --cert <cert> --key <key>

Better error:

Server 'prod.hessra.net' not configured.

Available servers: dev.hessra.net, test.hessra.net

Run: hessra config switch <server>
Or: hessra init prod.hessra.net --cert <cert> --key <key>

The best error messages include:

  1. What went wrong
  2. Context about the current state
  3. Specific commands to fix it

Key Patterns That Worked

1. Optimize for repeated operations. Track what users do frequently (delegating tokens, requesting auth tokens) and make those commands work with minimal flags after initial setup.

2. Cache aggressively. Public keys, CA certificates, and server metadata rarely change. Fetch once, cache, and provide a refresh command for updates.

3. Organize data by mental model. Structure persistent data by server in ~/.hessra/servers/<hostname>/ rather than dumping everything in one directory.

4. Provide escape hatches. Every smart default needs an override (--skip-fetch, --ca <path>). Make it easy to do the right thing, but never prevent advanced users from being explicit.

Conclusion

The best CLI tools are invisible. They anticipate what you need, remember what you told them, and get out of your way.

Building hessra-cli taught me that good CLI design is about respecting the user's time. Don't make them type the same flags every day. Don't make them parse your output with awk. Don't hide complexity forever, but don't shove it in their face either.

The key principles:

  • Start by imagining both human and automation usage
  • Build in smart defaults and persistent config for humans
  • Support explicit arguments and structured output for scripts
  • Establish clear configuration hierarchies with intelligent fallbacks
  • Make errors actionable with specific next steps

Most importantly: use your own tool daily. The friction you feel is the friction your users feel. Every time you think "I wish I didn't have to type that," you've found an opportunity to make the tool better.

If you're curious about the implementation, the hessra-cli source is at github.com/hessra/hessra-sdk.rs. The patterns discussed here - server-aware config, dual output modes, and configuration hierarchies - are all in the code.

Build tools that get out of your way. Your users (and your future self) will thank you.