Design and evaluate command-line tools for human users: naming and grammar, interactive prompts, colour and progress output, error messages, and a 0-21 usability rubric
72
91%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
How to optimize CLI startup time, implement spinners and progress bars, and keep the user informed about what's happening. A responsive CLI feels trustworthy — silence feels broken.
Users perceive CLI response under 100ms as instant, under 500ms as fast, and over 1 second as slow. Every invocation pays the startup cost, so it compounds across daily usage.
Benchmark: time mycli --version should complete in under 500ms. If it takes longer, profile and optimize.
| Bottleneck | Impact | Fix |
|---|---|---|
| Loading all plugins/commands | 200-2000ms | Lazy-load: only initialize the invoked subcommand |
| Network call at startup | 500-5000ms | Never block on network during startup; async update check |
| Large dependency import | 100-500ms per import | Defer imports to the subcommand that needs them |
| Config file parsing | 10-100ms | Cache parsed config; skip if not needed for the command |
| Shell completion setup | 10-50ms | Completion scripts are sourced by the shell, not the CLI |
Node.js — dynamic import:
// Don't import everything at the top
// import { heavyDep } from 'heavy-dep';
// Instead, import inside the command handler
program.command('deploy')
.action(async (opts) => {
const { heavyDep } = await import('heavy-dep');
// ... use heavyDep
});Python — deferred import:
# Instead of top-level: import boto3
# Import inside the function that needs it:
def deploy(env):
import boto3 # Only loaded when deploy is called
client = boto3.client('ecs')Go — init() avoidance:
// Don't do heavy work in init()
// func init() { loadAllPlugins() }
// Instead, load in the command's RunE
var deployCmd = &cobra.Command{
Use: "deploy",
RunE: func(cmd *cobra.Command, args []string) error {
plugins := loadPlugins() // Only when deploy is invoked
return doDeploy(plugins)
},
}Rust — feature flags:
# Cargo.toml — compile heavy features conditionally
[features]
default = ["core"]
deploy = ["aws-sdk", "docker"]Never block startup for update checks. Run them asynchronously or after the command completes:
import threading
def check_for_updates():
# Background thread — non-blocking
pass
# Start check in background, don't wait
threading.Thread(target=check_for_updates, daemon=True).start()
# After main command completes, show update notice if available
if update_available:
print("Update available: mycli 2.0.0. Run: mycli self-update", file=sys.stderr)Use a spinner when:
⠋ Deploying to production...
⠙ Deploying to production...
⠹ Deploying to production...
✓ Deployed to production (3.2s)✓ Done (3.2s) — reduces "was that fast or slow?" anxiety.Deploying... → Pushing image... → Updating manifest...| Framework | Library | Notes |
|---|---|---|
| Node.js | ora | De facto standard. TTY-aware, respects NO_COLOR |
| Node.js | nanospinner | Lighter alternative to ora |
| Python | yaspin | Decorator and context manager support |
| Python | rich.spinner | Part of the rich ecosystem |
| Python | halo | Similar API to ora |
| Go | briandowns/spinner | 90+ spinner styles |
| Go | charmbracelet/bubbles | Spinner component in Bubble Tea |
| Rust | indicatif | Spinner + progress bar in one crate |
| Rust | spinners | Lightweight spinner-only crate |
Use a progress bar when:
Downloading assets ████████████░░░░░░░░ 62% (31/50 MB) ETA: 12s| Component | Required? | Example |
|---|---|---|
| Label | Yes | Downloading assets |
| Bar | Yes | ████████░░░░░░ |
| Percentage | Yes | 62% |
| Count/size | Recommended | 31/50 MB or 150/240 items |
| ETA | Recommended | ETA: 12s |
| Speed | Optional | 2.5 MB/s |
| Elapsed | Optional | [00:15] |
Downloaded 50 MB in 28s (1.8 MB/s).Pulling image ████████████████████ 100%
Building app ████████░░░░░░░░░░░░ 40% ETA: 15s
Running tests ░░░░░░░░░░░░░░░░░░░░ 0% (waiting)| Framework | Library | Multi-bar | Notes |
|---|---|---|---|
| Node.js | cli-progress | Yes | Customizable format, multi-bar |
| Node.js | progress | No | Simpler API, single bar |
| Python | rich.progress | Yes | Best-in-class Python progress |
| Python | tqdm | Yes | Popular, pip-installable |
| Go | schollz/progressbar/v3 | No | Simple API |
| Go | vbauerster/mpb | Yes | Multiple bars, ETA |
| Rust | indicatif | Yes | Multi-progress, templates, ETA |
For multi-phase operations, show progress as a checklist:
$ mycli deploy production
✓ Building application (2.3s)
✓ Running tests (15.4s)
✓ Pushing container image (8.1s)
⠋ Updating deployment manifest...
○ Running health checks
○ Updating DNS
Step 4/6 — Updating deployment manifest✓, current step with a spinner, pending steps with ○.For operations taking more than 30 seconds, consider sending an OS notification on completion:
# macOS
osascript -e 'display notification "Deploy complete" with title "mycli"'
# Linux (libnotify)
notify-send "mycli" "Deploy complete"Rules:
--notify flag.Support --quiet / -q to suppress all non-essential output:
$ mycli deploy production --quiet
# Only errors printed to stderr. No progress, no spinners, no success message.
# Exit code tells the result: 0 = success, non-zero = failure.--quiet is essential for scripts that parse exit codes only. The CLI should still output data to stdout if the command's purpose is to produce output (e.g., mycli list --quiet still lists, just without decorations).