CtrlK
BlogDocsLog inGet started
Tessl Logo

wagneripjr/human-cli

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

Quality

91%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

performance-feedback.mdreferences/

Performance & Feedback

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.


Startup Performance

The 500ms rule

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.

Common startup bottlenecks

BottleneckImpactFix
Loading all plugins/commands200-2000msLazy-load: only initialize the invoked subcommand
Network call at startup500-5000msNever block on network during startup; async update check
Large dependency import100-500ms per importDefer imports to the subcommand that needs them
Config file parsing10-100msCache parsed config; skip if not needed for the command
Shell completion setup10-50msCompletion scripts are sourced by the shell, not the CLI

Lazy loading patterns

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"]

Update checks

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)

Spinners

When to use a spinner

Use a spinner when:

  • The operation takes more than 1 second
  • The total work is unknown (can't show a percentage)
  • Examples: API calls, DNS resolution, container pulls without progress data

Spinner design

⠋ Deploying to production...
⠙ Deploying to production...
⠹ Deploying to production...
✓ Deployed to production (3.2s)

Rules

  • Render on stderr. Stdout must remain clean for data.
  • Show elapsed time on completion: ✓ Done (3.2s) — reduces "was that fast or slow?" anxiety.
  • Clear the spinner line before printing results. Don't leave spinner artifacts.
  • Update the message as phases change: Deploying...Pushing image...Updating manifest...
  • Stop on error and show the failure message, not a frozen spinner.

Libraries

FrameworkLibraryNotes
Node.jsoraDe facto standard. TTY-aware, respects NO_COLOR
Node.jsnanospinnerLighter alternative to ora
PythonyaspinDecorator and context manager support
Pythonrich.spinnerPart of the rich ecosystem
PythonhaloSimilar API to ora
Gobriandowns/spinner90+ spinner styles
Gocharmbracelet/bubblesSpinner component in Bubble Tea
RustindicatifSpinner + progress bar in one crate
RustspinnersLightweight spinner-only crate

Progress Bars

When to use a progress bar

Use a progress bar when:

  • The operation takes more than 2 seconds
  • The total work is known (file size, item count, step count)
  • Examples: file downloads, batch processing, migrations, multi-step deploys

Progress bar design

Downloading assets ████████████░░░░░░░░ 62% (31/50 MB) ETA: 12s

Components

ComponentRequired?Example
LabelYesDownloading assets
BarYes████████░░░░░░
PercentageYes62%
Count/sizeRecommended31/50 MB or 150/240 items
ETARecommendedETA: 12s
SpeedOptional2.5 MB/s
ElapsedOptional[00:15]

Rules

  • Render on stderr. Same reasoning as spinners.
  • Update no faster than 10 times per second. More frequent updates cause flickering.
  • Show a final summary: Downloaded 50 MB in 28s (1.8 MB/s).
  • Degrade to a spinner when total is unknown (e.g., streaming API response of unknown size).
  • Multi-bar for parallel operations:
    Pulling image  ████████████████████ 100%
    Building app   ████████░░░░░░░░░░░░  40% ETA: 15s
    Running tests  ░░░░░░░░░░░░░░░░░░░░   0% (waiting)

Libraries

FrameworkLibraryMulti-barNotes
Node.jscli-progressYesCustomizable format, multi-bar
Node.jsprogressNoSimpler API, single bar
Pythonrich.progressYesBest-in-class Python progress
PythontqdmYesPopular, pip-installable
Goschollz/progressbar/v3NoSimple API
Govbauerster/mpbYesMultiple bars, ETA
RustindicatifYesMulti-progress, templates, ETA

Step-by-Step Feedback

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

Rules

  • Number the steps (4/6) so the user knows how far along they are.
  • Show elapsed time per step on completion.
  • Mark completed steps with , current step with a spinner, pending steps with .
  • Don't re-render completed steps. Scroll down, don't repaint the screen (unless using a TUI library).

OS Notifications

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:

  • Opt-in only. Don't send notifications unless the user enables them via config or --notify flag.
  • Only on success or failure. Don't notify for warnings or informational messages.
  • Include the result: "Deploy to production succeeded" not just "Deploy complete."

Silent Mode

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).

SKILL.md

tile.json