| name | hashimoto-cli-ux |
| description | Design CLI tools in the style of Mitchell Hashimoto, founder of HashiCorp. Emphasizes consistent command patterns, helpful error messages, progressive disclosure, and machine-readable output. Use when building command-line tools that developers will love. |
| tags | cli, command-line, terminal, flags, help-text, output-formatting, progressive-disclosure, ux, tool, shell |
Mitchell Hashimoto CLI UX Style Guide
Overview
Mitchell Hashimoto founded HashiCorp and created some of the most beloved developer tools: Vagrant, Terraform, Consul, Vault, and Nomad. These tools share a consistent, thoughtful CLI design that has become the gold standard for developer experience. Hashimoto's CLIs are famous for being discoverable, helpful, and powerful without being overwhelming.
Core Philosophy
"A CLI should be a conversation, not a puzzle."
"Error messages are documentation. Write them like you're helping a colleague."
"The best CLI is one you can use without reading the docs first."
Hashimoto believes that CLIs should respect the user's time and intelligence. They should be easy to explore, provide helpful feedback, and never leave the user guessing what went wrong or what to do next.
Design Principles
-
Consistent Command Structure: <tool> <noun> <verb> [options] or <tool> <command> [options]
-
Progressive Disclosure: Simple by default, powerful when needed.
-
Helpful Error Messages: Tell users what went wrong AND how to fix it.
-
Machine-Readable Output: Always support --json or -o json for scripting.
-
Discoverability: --help at every level, tab completion, command suggestions.
Command Structure
HashiCorp Command Pattern:
──────────────────────────
terraform <command> [options] [args]
│ │ │ │
│ │ │ └── Positional arguments
│ │ └── Flags modify behavior
│ └── The action (init, plan, apply, destroy)
└── The tool name
Examples:
terraform init
terraform plan -out=tfplan
terraform apply tfplan
terraform destroy -auto-approve
vault secrets list
vault secrets enable -path=secret kv
vault kv put secret/myapp password=s3cr3t
vault kv get -format=json secret/myapp
consul services register web.json
consul services deregister web
consul kv put config/db/host 10.0.0.1
consul kv get -recurse config/
When Designing CLIs
Always
- Provide
--help at every command level
- Support
-h as alias for --help
- Include examples in help text
- Support
--version and -v
- Provide JSON output option (
--json or -format=json)
- Use exit codes consistently (0=success, 1=error)
- Show progress for long operations
- Suggest corrections for typos
Never
- Require reading docs to use basic features
- Output errors without suggested fixes
- Mix output and errors on stdout
- Require interactive input without a non-interactive option
- Break backward compatibility silently
- Use inconsistent flag names across commands
- Ignore terminal width when formatting
Prefer
- Subcommands over many flags
- Long flags over short for clarity (
--verbose over -v)
- Confirmation prompts for destructive actions
- Color output (with
--no-color option)
- Table output for humans, JSON for machines
- Stdin support for piping
Code Patterns
Command Structure with Clap (Rust)
use clap::{Parser, Subcommand, Args, ValueEnum};
#[derive(Parser)]
#[command(name = "sk1llz")]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
#[command(after_help = "Examples:
sk1llz list List all available skills
sk1llz search rust Search for Rust-related skills
sk1llz install torvalds Install a skill by name
sk1llz info lamport Show details about a skill
Use 'sk1llz <command> --help' for more information about a command.")]
struct Cli {
#[arg(long, short = 'o', global = true, value_enum, default_value = "text")]
format: OutputFormat,
#[arg(long, global = true)]
no_color: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(ValueEnum, Clone, Copy)]
enum OutputFormat {
Text,
Json,
}
#[derive(Subcommand)]
enum Commands {
#[command(visible_alias = "ls")]
List(ListArgs),
Search(SearchArgs),
Info(InfoArgs),
Install(InstallArgs),
Uninstall(UninstallArgs),
Init,
Update,
Where,
Doctor,
Completions {
shell: Shell,
},
}
{
category: <>,
tag: <>,
}
{
name: ,
global: ,
yes: ,
}
Helpful Error Messages
use thiserror::Error;
use colored::Colorize;
#[derive(Error, Debug)]
enum CliError {
#[error("Skill '{name}' not found")]
SkillNotFound {
name: String,
suggestions: Vec<String>,
},
#[error("No .claude directory found")]
NoClaudeDir,
#[error("Network error: {message}")]
Network { message: String },
#[error("Manifest is stale")]
StaleManifest { days_old: u64 },
}
impl CliError {
pub fn display(&self) -> String {
match self {
CliError::SkillNotFound { name, suggestions } => {
let mut msg = format!(
"{} Skill '{}' not found.\n",
"Error:".red().bold(),
name.yellow()
);
if !suggestions.is_empty() {
msg.push_str(&format!(
"\n{}\n",
"Did you mean one of these?".()
));
suggestions.().() {
msg.(&(, suggestion.()));
}
}
msg.(&(
,
.().(),
.()
));
msg
}
CliError::NoClaudeDir => {
(
,
.().(),
.().(),
.(),
.().(),
.()
)
}
CliError::Network { message } => {
(
,
.().(),
message,
.().(),
.()
)
}
CliError::StaleManifest { days_old } => {
(
,
.().(),
days_old,
.().(),
.()
)
}
}
}
}
(query: &, skills: &[Skill]) <> {
fuzzy_matcher::skim::SkimMatcherV2;
fuzzy_matcher::FuzzyMatcher;
= SkimMatcherV2::();
: <_> = skills
.()
.(|s| {
= matcher.(&s.name, query)?;
score > {
((s.name.(), score))
} {
}
})
.();
scored.(|a, b| b..(&a.));
scored.().().(|(name, _)| name).()
}
JSON Output Support
use serde::Serialize;
#[derive(Serialize)]
struct SkillInfo {
name: String,
id: String,
category: String,
description: String,
tags: Vec<String>,
installed: bool,
path: Option<String>,
}
fn cmd_info(name: &str, format: OutputFormat) -> Result<()> {
let skill = find_skill(name)?;
let info = SkillInfo {
name: skill.name.clone(),
id: skill.id.clone(),
category: skill.category.clone(),
description: skill.description.clone(),
tags: skill.tags.clone(),
installed: check_installed(&skill),
path: get_install_path(&skill),
};
match format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&info)?);
}
OutputFormat::Text => {
println!("\n{}", info.name.bold().cyan().underline());
println!(, .(), info.id);
(, .(), info.category);
(, .());
(, info.description);
info.installed {
(, .(), .());
(path) = info.path {
(, path.());
}
}
}
}
(())
}
(args: ListArgs, format: OutputFormat) <()> {
= ()?;
= (&manifest.skills, &args);
format {
OutputFormat::Json => {
= serde_json::json!({
: skills.(),
: skills,
});
(, serde_json::(&output)?);
}
OutputFormat::Text => {
(&skills);
}
}
(())
}
Init Command
fn cmd_init() -> Result<()> {
let cwd = std::env::current_dir()?;
let claude_dir = cwd.join(".claude");
let skills_dir = claude_dir.join("skills");
if skills_dir.exists() {
println!(
"{} Project already initialized at {}",
"✓".green().bold(),
skills_dir.display().to_string().cyan()
);
return Ok(());
}
fs::create_dir_all(&skills_dir)?;
fs::write(skills_dir.join(".gitkeep"), "")?;
println!(
"{} Initialized sk1llz in {}\n",
"✓".green().bold(),
skills_dir.display().to_string().cyan()
);
println!("{}", .());
();
(, .());
();
(, .());
();
(())
}
Uninstall Command
fn cmd_uninstall(name: &str, yes: bool) -> Result<()> {
let (local, global) = get_skill_locations();
let mut found_at: Option<PathBuf> = None;
if let Some(local_path) = &local {
let skill_path = local_path.join(name);
if skill_path.exists() {
found_at = Some(skill_path);
}
}
if found_at.is_none() {
let skill_path = global.join(name);
if skill_path.exists() {
found_at = Some(skill_path);
}
}
let path = found_at.ok_or_else(|| {
anyhow::anyhow!(
"Skill '{}' is not installed.\n\n\
{} Use '{}' to see installed skills.",
name,
"Hint:".blue().bold(),
"sk1llz where".cyan()
)
})?;
if !yes {
println!(
,
.().(),
name.(),
path.().().()
);
();
io::().()?;
= ::();
io::().(& input)?;
input.().() != {
(, .());
(());
}
}
fs::(&path)?;
(
,
.().(),
name.(),
path.().().()
);
(())
}
Doctor Command
fn cmd_doctor() -> Result<()> {
println!("\n{}", "sk1llz doctor".bold().cyan());
println!("{}\n", "Checking your setup...".dimmed());
let mut issues = Vec::new();
print!(" Checking cache directory... ");
match get_cache_dir() {
Ok(path) if path.exists() => {
println!("{}", "OK".green());
}
Ok(path) => {
println!("{}", "MISSING".yellow());
issues.push(format!(
"Cache directory doesn't exist: {}\n Fix: Run 'sk1llz update'",
path.display()
));
}
Err(e) => {
println!("{}", "ERROR".red());
issues.push(format!(, e));
}
}
();
() {
(days) days < => {
(, .(), days);
}
(days) => {
(, .(), days);
issues.(.());
}
(_) => {
(, .());
issues.(.());
}
}
();
(local, global) = ();
local.() || global.() {
(, .());
} {
(, .());
issues.(
.()
);
}
();
reqwest::blocking::(MANIFEST_URL) {
(r) r.().() => {
(, .());
}
_ => {
(, .());
issues.(.());
}
}
();
issues.() {
(, .().());
} {
(, .().(), issues.());
issues {
(, issue);
}
}
(())
}
() <> {
= ()?;
= fs::(&path)?;
= metadata.()?;
= SystemTime::().(modified)?;
(age.() / )
}
Progress and Confirmation
use dialoguer::{Confirm, theme::ColorfulTheme};
use indicatif::{ProgressBar, ProgressStyle};
fn confirm_destructive_action(message: &str) -> bool {
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(message)
.default(false)
.interact()
.unwrap_or(false)
}
fn create_progress_bar(len: u64, message: &str) -> ProgressBar {
let pb = ProgressBar::new(len);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("█▓░"),
);
pb.set_message(message.to_string());
pb
}
fn create_spinner(message: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template()
.(),
);
pb.(message.());
pb.(std::time::Duration::());
pb
}
Mental Model
Hashimoto approaches CLI design by asking:
- Can a new user figure this out? Discoverability is key
- What will they try first? Support the obvious path
- What goes wrong? Write errors that help, not blame
- Can it be scripted? Always support machine output
- Is it consistent? Same patterns across all commands
The CLI UX Checklist
□ --help at every command level with examples
□ --version returns clean version string
□ --json or -o json for machine output
□ Errors include what went wrong AND how to fix
□ Tab completion script generation
□ Confirmation for destructive actions
□ Progress indicators for slow operations
□ "Did you mean?" for typos
□ Consistent flag names across commands
□ Non-zero exit codes on error
□ Stdin support where it makes sense
□ --no-color for accessibility
Signature Hashimoto Moves
- Consistent
<tool> <command> [args] pattern
- Error messages with fix suggestions
- Machine-readable
--json output
- Progressive disclosure (simple defaults, powerful options)
- Tab completion for all commands
doctor command for diagnosing issues
- Confirmation prompts for destructive actions
- Examples in
--help output