From nushell-dev
This skill should be used when the user asks to "build a CLI tool", "create command-line interface", "CLI best practices", "clig.dev guidelines", "help text patterns", "CLI arguments", "CLI error messages", "CLI output formatting", "human-first design", "CLI interactivity", "exit codes", "CLI configuration", "environment variables", or mentions building user-facing Nushell scripts, commands, or tools that should follow professional CLI design principles.
npx claudepluginhub danielbodnar/nushell-dev --plugin nushell-devThis skill uses the workspace's default tool permissions.
Comprehensive reference for building excellent command-line interfaces in Nushell, based on [clig.dev](https://clig.dev) principles and adapted for Nushell's structured data paradigm. Great CLIs are not just functional - they are empathetic, discoverable, consistent, and robust.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
Comprehensive reference for building excellent command-line interfaces in Nushell, based on clig.dev principles and adapted for Nushell's structured data paradigm. Great CLIs are not just functional - they are empathetic, discoverable, consistent, and robust.
Command-line interfaces are often the primary way developers and power users interact with tools. A well-designed CLI:
The eight principles that guide excellent CLI design:
Humans come first, machines second. Optimize for human understanding by default.
# Human-friendly: structured output Nushell displays beautifully
def "files analyze" [path: path = "."] -> table {
ls $path
| where type == "file"
| select name size modified
| sort-by size --reverse
| first 10
}
# Machine-readable when needed: one pipe away
# files analyze | to json
# files analyze | to csv
Nushell advantage: Tables and records are both human-readable AND machine-parseable. No need to choose.
Do one thing well. Build small, focused commands that compose via pipelines.
# GOOD: Single responsibility, composable
export def "git branches-merged" [] {
git branch --merged
| lines
| where $it !~ '\*'
| str trim
}
# Can be composed: git branches-merged | each { git branch -d $in }
# BAD: Trying to do too much
export def "git-cleanup-everything" [] {
# Fetches, prunes, deletes merged, deletes stale, garbage collects...
# Violates single responsibility
}
Users build mental models. Respect them. Use standard flag names and behaviors.
| Short | Long | Purpose |
|---|---|---|
-h | --help | Show help text |
-v | --verbose | More detailed output |
-q | --quiet | Suppress non-error output |
-f | --force | Skip confirmations |
-n | --dry-run | Preview without changes |
-o | --output | Output file or destination |
-r | --recursive | Include subdirectories |
# Consistent across your entire toolset
export def "backup create" [
source: path
destination: path
--verbose (-v)
--quiet (-q)
--force (-f)
--recursive (-r)
--dry-run (-n)
] {
# Users learn once, apply everywhere
}
Balance silence and verbosity. Default to useful information, not noise.
# Default: Essential information only
export def "deploy status" [--verbose (-v)] {
let status = get-deployment-status
if $verbose {
# Full details when requested
$status | table --expand
} else {
# Concise default
print $"Status: ($status.state) | Uptime: ($status.uptime)"
}
}
# Silent success for scripts
export def "cache clear" [--quiet (-q)] {
rm -rf ~/.cache/myapp
if not $quiet {
print "Cache cleared successfully"
}
}
Users should learn your CLI by using it. Make help accessible and thorough.
# Nushell's def comments become built-in help
# Run: help task add
# Add a new task to the task list
#
# Creates a task with the given title and optional metadata.
# Tasks are stored in ~/.local/share/tasks/tasks.nuon
#
# Examples:
# task add "Fix bug in parser"
# task add "Review PR" --priority 5 --due 2024-03-15
# task add "Weekly meeting" --tags ["recurring", "team"]
export def "task add" [
title: string # The task title (required)
--priority (-p): int # Priority level 1-5 (default: 3)
--due (-d): datetime # Due date in any parseable format
--tags (-t): list<string> # Tags for categorization
] {
# Implementation
}
# Typing `task` alone shows available subcommands
export def "task" [] {
print "Task management commands:"
print ""
print " task add - Create a new task"
print " task list - Show all tasks"
print " task complete - Mark task as done"
print " task delete - Remove a task"
print ""
print "Run 'help task <command>' for details"
}
Treat CLI interaction as a dialogue. Confirm destructive actions and show progress.
# Confirmation for destructive actions
export def "data purge" [
--force (-f) # Skip confirmation
--dry-run (-n) # Show what would be deleted
] {
let items = (ls data/ | length)
if $dry_run {
print $"Would delete ($items) items"
return
}
if not $force {
let confirm = input $"Delete ($items) items? This cannot be undone. [y/N] "
if ($confirm | str downcase) != "y" {
print "Aborted."
return
}
}
rm -rf data/*
print $"Deleted ($items) items"
}
# Progress for long operations
export def "sync remote" [--verbose (-v)] {
let files = (ls -r src/ | where type == "file")
let total = ($files | length)
$files | enumerate | each { |item|
if $verbose {
print -e $"Syncing [($item.index + 1)/($total)]: ($item.item.name)"
}
upload-file $item.item.name
}
print $"Synced ($total) files"
}
Handle edge cases gracefully. Fail fast with clear messages.
export def "file process" [path: path] {
# Validate early with helpful messages
if not ($path | path exists) {
error make {
msg: $"File not found: ($path)"
help: "Check the path and try again. Use 'ls' to see available files."
}
}
if ($path | path type) != "file" {
error make {
msg: $"Expected a file, got: ($path | path type)"
help: "Use --recursive for directories"
}
}
# Wrap risky operations
try {
open $path | process-contents
} catch { |err|
error make {
msg: $"Failed to process ($path)"
help: $"File may be corrupted or unsupported format.\nOriginal: ($err.msg)"
}
}
}
Remember: users are often stressed, debugging, or learning. Be helpful.
# Helpful error messages with actionable suggestions
export def "config validate" [path: path = "config.toml"] {
if not ($path | path exists) {
print $"(ansi red)Error:(ansi reset) Config file not found: ($path)"
print ""
print "To create a default config:"
print $" (ansi cyan)config init(ansi reset)"
print ""
print "Or specify a different path:"
print $" (ansi cyan)config validate /path/to/config.toml(ansi reset)"
exit 1
}
# Validate with specific fix suggestions
let errors = validate-config $path
if ($errors | length) > 0 {
print $"(ansi yellow)Found ($errors | length) issue(s):(ansi reset)"
$errors | each { |e|
print $" Line ($e.line): ($e.message)"
if ($e.suggestion | is-not-empty) {
print $" (ansi dim)Fix: ($e.suggestion)(ansi reset)"
}
}
}
}
Nushell inherently supports many CLI guidelines through its design:
| CLI Guideline | Nushell Feature |
|---|---|
| Structured output | Tables, records, lists - native data types |
| Type safety | Built-in type system validates input |
| Self-documenting | def comments become help output |
| Error handling | try/catch with rich error messages |
| Composability | Pipeline-native, structured data flows |
| Discoverability | Tab completion, help system |
| CLI Concept | Nushell Feature | Example |
|---|---|---|
| Help text | Documentation comments | # Description\ndef cmd [] |
| Arguments | def parameters | def cmd [file: path] |
| Flags | Named parameters | --verbose (-v) |
| Optional args | Default values | file: string = "default" |
| Rest args | Spread parameter | ...files: path |
| Subcommands | Space-separated names | def "git commit" [] |
| Stdout | Return values | $data |
| Stderr | print -e | print -e "Warning" |
| Exit codes | exit command | exit 1 |
| Machine output | Format conversions | | to json, | to csv |
| Config | Environment + files | $env.MY_CONFIG, open ~/.config/ |
| stdin | $in variable | $in | each {} |
# Fetch data from a remote API endpoint
#
# Retrieves JSON data and returns it as a Nushell table.
# Supports authentication via environment variable or flag.
#
# Examples:
# fetch-api https://api.example.com/users
# fetch-api https://api.example.com/users --token $env.API_TOKEN
# fetch-api https://api.example.com/users | where active == true
def fetch-api [
url: string # The API endpoint URL
--token (-t): string # Authentication token (or use $env.API_TOKEN)
--timeout: duration = 30sec # Request timeout
--raw (-r) # Return raw response without parsing
] -> table {
# Implementation
}
# AVOID: Positional args for optional behavior
def bad-copy [source: path, dest: path, recursive: bool] { }
# PREFER: Flags for optional behavior
def copy-files [
source: path # Source file or directory
dest: path # Destination path
--recursive (-r) # Copy directories recursively
--force (-f) # Overwrite without prompting
--preserve (-p) # Preserve file attributes
] { }
def list-services [] -> table {
# Structured data - beautiful for humans, convertible for machines
[
{ name: "api", status: "running", port: 8080, uptime: 5day }
{ name: "db", status: "running", port: 5432, uptime: 12day }
{ name: "cache", status: "stopped", port: null, uptime: null }
]
}
def get-status [
--json (-j) # Output as JSON
--quiet (-q) # Output only status code
] {
let status = get-current-status
if $quiet { $status.code }
else if $json { $status | to json }
else { $status }
}
def process-file [file: path] {
if not ($file | path exists) {
error make {
msg: $"File not found: ($file)"
help: "Check the file path. Use 'ls' to see available files."
}
}
try {
open $file | process-contents
} catch { |err|
error make {
msg: $"Failed to process ($file)"
help: $"File may be corrupted.\nOriginal: ($err.msg)"
}
}
}
| Code | Meaning | Use Case |
|---|---|---|
| 0 | Success | Operation completed |
| 1 | General error | Something went wrong |
| 2 | Usage error | Invalid arguments/flags |
| 78 | Config error | Configuration problem |
| 126 | Not executable | Permission issue |
| 127 | Not found | Command/file missing |
Priority from highest to lowest:
~/.config/myapp/)/etc/myapp/)# Typing `myapp` alone shows help
def "myapp" [] { help myapp }
def "myapp init" [name: string, --template (-t): string] { }
def "myapp build" [--release (-r), --target: string] { }
def "myapp deploy" [environment: string, --dry-run] { }
def process-files [...files: path, --quiet (-q)] {
let total = ($files | length)
$files | enumerate | each { |item|
if not $quiet {
print -e $"Processing [($item.index + 1)/($total)]: ($item.item | path basename)"
}
process-single-file $item.item
}
if not $quiet {
print -e $"Completed processing ($total) files"
}
}
def dangerous-operation [
target: path
--force (-f) # Skip confirmation
--dry-run (-n) # Show what would happen
] {
if $dry_run {
print $"Would delete: ($target)"
return
}
# Check if interactive
let is_interactive = (term size | is-not-empty)
if not $force and $is_interactive {
let confirm = input $"Delete ($target)? [y/N] "
if $confirm not-in ["y", "Y", "yes"] {
print "Aborted"
return
}
}
rm -rf $target
print $"Deleted: ($target)"
}
| Anti-Pattern | Problem | Better Approach |
|---|---|---|
| Printing JSON directly | Loses Nushell's power | Return records/tables |
| Hardcoded paths | Not portable | Use XDG, $env.HOME |
| Silent failures | Users don't know what happened | Always report errors |
| Cryptic flags | -xvzf is hard to remember | Meaningful long names |
| No help text | Undiscoverable | Document everything |
| Prompts in non-interactive | Breaks scripts | Check TTY, use --force |
| Mixing output and status | Hard to parse | Data to stdout, status to stderr |
| Secrets as flags | Visible in ps | Use env vars or files |
# Check if running in terminal
def is-terminal [] -> bool {
(term size | is-not-empty)
}
# Prompt only when interactive
def get-input [prompt: string, --no-input] -> string {
if $no_input {
error make { msg: "Input required. Use appropriate flag." }
}
if (is-terminal) {
input $prompt
} else {
error make {
msg: "Cannot prompt: not running in terminal"
help: "Provide value via flags or use --no-input mode"
}
}
}
# Semantic colors
def print-error [msg: string] {
if ($env.NO_COLOR? | is-empty) {
print -e $"(ansi red)error:(ansi reset) ($msg)"
} else {
print -e $"error: ($msg)"
}
}
def print-warning [msg: string] {
if ($env.NO_COLOR? | is-empty) {
print -e $"(ansi yellow)warning:(ansi reset) ($msg)"
} else {
print -e $"warning: ($msg)"
}
}
def print-success [msg: string] {
if ($env.NO_COLOR? | is-empty) {
print $"(ansi green)success:(ansi reset) ($msg)"
} else {
print $"success: ($msg)"
}
}
Color conventions:
Detailed documentation in references/:
philosophy.md - Deep dive into clig.dev principlesarguments.md - Arguments, flags, and input patternsoutput.md - Output formatting and colorerrors.md - Error handling and exit codeshelp.md - Help text and documentationconfiguration.md - Config files and environment variablesinteractivity.md - TTY detection and promptsrobustness.md - Edge cases and future-proofinglinting-rules.md - Automated CLI quality checksWorking examples in examples/:
cli-compliant-script.nu - Complete CLI following all guidelinesUtility scripts in scripts/:
validate-cli.nu - Check if a CLI follows guidelines