
Cli For Agents
- 167 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
cli-for-agents: A skill for development. This provides functionality for development workflows.
Key points
- cli-for-agents
Cli For Agents by the numbers
- 167 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #2,281 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill cli-for-agentsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 167 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use cli-for-agents for development tasks?
Use cli-for-agents for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with cli-for-agents.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use cli-for-agents for development tasks, or when cli-for-agents: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to cli-for-agents: cli-for-agents.
Files
Agent-Friendly CLI Design Best Practices
Prescriptive design and review standards for Command-Line Interface Design targeting AI agents and scripts, not just humans typing at a prompt. Human-oriented CLIs often block agents: interactive prompts, huge upfront docs, help text without copy-pasteable examples, error messages without fixes, no dry-run mode. This skill prioritizes rules by blast radius — from "the agent cannot use this CLI at all" (CRITICAL) to "the agent has to read help one extra time" (MEDIUM).
Use this skill both when building a new CLI and when reviewing an existing one for agent-friendliness.
This skill contains 45 rules across 8 categories.
When to Apply
Reference these guidelines when:
- Writing
--helptext for any subcommand - Designing new flags, arguments, or subcommands
- Crafting error messages or exit codes
- Adding destructive operations that need dry-run or confirmation
- Choosing between interactive prompts and flag-only inputs
- Shaping success output so agents can chain commands
- Reviewing an existing CLI for headless-usability regressions
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Non-interactive Operation | CRITICAL | interact- |
| 2 | Help Text Design | HIGH | help- |
| 3 | Error Messages | HIGH | err- |
| 4 | Destructive Action Safety | HIGH | safe- |
| 5 | Input Handling | HIGH | input- |
| 6 | Output Format | MEDIUM-HIGH | output- |
| 7 | Idempotency & Retries | MEDIUM-HIGH | idem- |
| 8 | Command Structure | MEDIUM | struct- |
Note: help-examples-in-help is rated CRITICAL within the HIGH help- category because its specific failure — help text without examples — makes every other help rule moot. The category label reflects the average, not the worst case.
Quick Reference
1. Non-interactive Operation (CRITICAL)
- `interact-flags-first` — Express every input as a flag first; prompts are TTY-only fallback
- `interact-detect-tty` — Check
isatty()before prompting - `interact-no-arrow-menus` — Replace arrow-key menus with flag-selected choices
- `interact-no-input-flag` — Support
--no-inputto force non-interactive mode - `interact-no-timed-prompts` — Never use timed prompts or press-any-key screens
- `interact-no-hang-on-stdin` — Don't block on stdin when a TTY is attached
2. Help Text Design (HIGH)
- `help-examples-in-help` — Include copy-pasteable examples in every
--help - `help-per-subcommand` — Every subcommand owns its own
--help - `help-no-flag-required` — Show help when invoked with zero arguments
- `help-layered-discovery` — Top-level help is a navigational index
- `help-flag-summary` — List both short and long forms for every flag
- `help-suggest-next-steps` — Suggest what to run next in help and success output
3. Error Messages (HIGH)
- `err-exit-fast-on-missing-required` — Exit fast on missing required flags
- `err-actionable-fix` — Include a concrete fix in every error message
- `err-stderr-not-stdout` — Send errors to stderr, not stdout
- `err-non-zero-exit-codes` — Use distinct non-zero exit codes for distinct failures
- `err-include-example-invocation` — Include a correct example invocation in errors
- `err-no-stack-traces-by-default` — Reserve stack traces for
--debugmode
4. Destructive Action Safety (HIGH)
- `safe-dry-run-flag` — Provide
--dry-runfor every destructive command - `safe-force-bypass-flag` — Provide
--yes/--forceto skip confirmations - `safe-confirm-by-typing-name` — Require typing the resource name for irreversible actions
- `safe-no-prompts-with-no-input` — Never prompt when
--no-inputis set - `safe-idempotent-cleanup` — Exit successfully when delete targets are already gone
- `safe-crash-only-recovery` — Design multi-step commands for crash-only recovery
5. Input Handling (HIGH)
- `input-accept-stdin-dash` — Accept
-as filename for stdin and stdout - `input-flags-over-positional` — Prefer named flags over positional arguments
- `input-stdin-for-secrets` — Accept secrets through stdin or file, never as flag values
- `input-env-var-fallback` — Accept common flags through environment variables
- `input-no-prompt-fallback` — Never fall back to a prompt when a flag is missing
6. Output Format (MEDIUM-HIGH)
- `output-json-flag` — Provide
--jsonfor stable machine-readable output - `output-ndjson-streaming` — Stream large result sets as NDJSON
- `output-bounded-by-default` — Bound default output size with
--limitand--all - `output-machine-ids-on-success` — Return chainable values on success, not just "Done"
- `output-respect-no-color` — Disable ANSI color when
NO_COLORor non-TTY - `output-no-decorative-only` — Avoid relying on decorative output to convey state
- `output-one-record-per-line` — One record per line for grep-able human output
7. Idempotency & Retries (MEDIUM-HIGH)
- `idem-retry-safe` — Make running the same command twice safe
- `idem-create-or-skip` — Make create commands skip when target already exists
- `idem-stable-output-on-skip` — Return the same output shape whether acting or skipping
- `idem-state-reconciliation` — Prefer "ensure state" semantics over delta application
- `idem-stable-identifiers` — Accept user-provided names instead of auto-generating IDs
8. Command Structure (MEDIUM)
- `struct-resource-verb` — Use a consistent resource-verb command shape
- `struct-flag-order-independent` — Parse flags in any position relative to subcommands
- `struct-no-hidden-subcommand-catchall` — Avoid catch-all handlers for unknown subcommands
- `struct-standard-flag-names` — Use standard flag names (
--help,--version,--verbose,--quiet)
How to Use
When building a new CLI
Start at CRITICAL and walk down. The first two categories (interact- and help-) are non-negotiable — if any rule in these is violated, the CLI is unusable by agents regardless of how good the rest is. After those, work through err-, safe-, and input- — these are where most real-world friction lives. output-, idem-, and struct- are polish that compounds across many invocations.
When reviewing an existing CLI
Run through this checklist in priority order:
1. Non-interactive path — invoke every subcommand with --no-input or under </dev/null and see which hang 2. Layered help — does mycli --help list subcommands only, or dump everything? 3. Examples on `--help` — every subcommand's help should end with a runnable Examples section 4. Error messages with invocations — does every error tell the caller exactly which flag to add? 5. stdin/pipeline story — can you pipe output into input? Does - mean stdin? 6. Exit codes — are usage errors (2), runtime failures (1), and transient failures (69) distinct? 7. Dry-run — every destructive command has --dry-run (or equivalent) 8. Confirmation bypass — every destructive command has --yes/--force 9. Consistent command structure — do service list, deploy list, config list all exist and work the same way? 10. Structured success output — does deploy return a deploy_id the agent can use next?
Individual rules
Read individual reference files for detailed explanations and code examples:
- Section definitions — Category structure and impact levels
- Rule template — Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
CLI Design
Version 0.1.0 Agent-Friendly April 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Prescriptive design and review standards for command-line tools that AI agents and automation will invoke. Contains 45 rules across 8 categories, prioritized by blast radius from CRITICAL (non-interactive operation) through HIGH (help text design, error messages, destructive action safety, input handling) and MEDIUM-HIGH (output format, idempotency) to MEDIUM (command structure). Each rule explains the failure mode in concrete terms, provides production-realistic incorrect and correct examples in multiple languages (TypeScript, Python, Go, Rust, Bash), and links to authoritative sources including clig.dev, the Heroku CLI Style Guide, GNU Coding Standards, POSIX utility conventions, no-color.org, and the JSON Lines spec. Used both when building new CLIs and when reviewing existing ones for agent-friendliness regressions.
---
Table of Contents
1. Non-interactive Operation — CRITICAL
- 1.1 Avoid Blocking on stdin When a TTY Is Attached — HIGH (prevents indefinite hangs when no pipe is provided)
- 1.2 Check for a TTY Before Prompting — CRITICAL (prevents indefinite hangs under agents and CI)
- 1.3 Express Every Input as a Flag First — CRITICAL (prevents indefinite hangs in headless environments)
- 1.4 Never Use Timed Prompts or Press-Any-Key Screens — HIGH (prevents wall-clock waste on every retry)
- 1.5 Replace Arrow-Key Menus with Flag-Selected Choices — CRITICAL (prevents blocking on inputs agents cannot produce)
- 1.6 Support a --no-input Flag to Force Non-Interactive Mode — HIGH (prevents prompts inside harnesses that falsely report TTY)
2. Help Text Design — HIGH
- 2.1 Every Subcommand Owns Its Own --help — HIGH (reduces loaded help context by 80-95%)
- 2.2 Include Copy-Pasteable Examples in Every --help — CRITICAL (reduces invocation guessing to O(1) lookup)
- 2.3 List Both Short and Long Forms for Every Flag — HIGH (prevents brittle single-letter scripts and collision bugs)
- 2.4 Show Help When Invoked With Zero Arguments — HIGH (prevents silent side effects from discovery attempts)
- 2.5 Structure Top-Level Help as a Navigational Index — HIGH (reduces top-level discovery context from ~200 lines to ~15)
- 2.6 Suggest What to Run Next in Help and Success Output — HIGH (prevents round-trips to top-level help for related commands)
3. Error Messages — HIGH
- 3.1 Exit Fast on Missing Required Flags — HIGH (prevents wasted wall-clock on every retry)
- 3.2 Include a Concrete Fix in Every Error Message — HIGH (reduces retry loops by collapsing guess-and-check to one round)
- 3.3 Include a Correct Example Invocation in Error Messages — HIGH (reduces re-reads of --help after a failed command)
- 3.4 Reserve Stack Traces for --debug Mode — MEDIUM-HIGH (reduces default error output by 10-50x)
- 3.5 Send Errors and Warnings to stderr, Not stdout — HIGH (prevents error text from corrupting piped data)
- 3.6 Use Distinct Non-Zero Exit Codes for Distinct Failures — HIGH (prevents silent failures and unnecessary retries)
4. Destructive Action Safety — HIGH
- 4.1 Design Multi-Step Commands for Crash-Only Recovery — MEDIUM-HIGH (prevents stuck-state requiring manual intervention)
- 4.2 Exit Successfully When Delete Targets Are Already Gone — MEDIUM-HIGH (prevents retry-loop errors on already-clean state)
- 4.3 Never Prompt When --no-input Is Set — HIGH (prevents silent fallback to prompts in scripted mode)
- 4.4 Provide --dry-run for Every Destructive Command — HIGH (prevents irreversible mistakes during agent exploration)
- 4.5 Provide --yes or --force to Skip Confirmation Prompts — HIGH (prevents confirmation prompts from blocking scripted runs)
- 4.6 Require Typing the Resource Name for Irreversible Actions — HIGH (prevents muscle-memory past safe-by-default y/N prompts)
5. Input Handling — HIGH
- 5.1 Accept `-` as Filename for stdin and stdout — HIGH (prevents pipeline composition workarounds and temp files)
- 5.2 Accept Common Flags Through Environment Variables — MEDIUM-HIGH (prevents repetition of the same flag on every invocation)
- 5.3 Accept Secrets Through stdin or File, Never as Flag Values — HIGH (prevents secret leakage into ps output, shell history, and logs)
- 5.4 Never Fall Back to a Prompt When a Flag Is Missing — MEDIUM-HIGH (prevents silent hangs when TTY detection misfires)
- 5.5 Prefer Named Flags Over Positional Arguments — HIGH (prevents argument-order guessing and future breakage)
6. Output Format — MEDIUM-HIGH
- 6.1 Avoid Relying on Decorative Output to Convey State — MEDIUM (prevents state from being lost when agents read raw bytes)
- 6.2 Bound Default Output Size with --limit and --all — MEDIUM-HIGH (prevents agent context blowup on default list invocations)
- 6.3 Disable ANSI Color When NO_COLOR or Non-TTY — MEDIUM (prevents escape sequences from breaking regex matches)
- 6.4 Emit One Record Per Line for Grep-Able Human Output — MEDIUM (prevents grep/awk/cut breakage on borders and wrapped cells)
- 6.5 Provide --json for Stable Machine-Readable Output — MEDIUM-HIGH (prevents brittle regex parsing of human-readable tables)
- 6.6 Return Chainable Values on Success, Not Just "Done" — MEDIUM-HIGH (prevents round-trip lookups for IDs/URLs of just-created resources)
- 6.7 Stream Large Result Sets as NDJSON — MEDIUM-HIGH (prevents agent context blowup on large list commands)
7. Idempotency & Retries — MEDIUM-HIGH
- 7.1 Accept User-Provided Names Instead of Auto-Generating IDs — MEDIUM (prevents orphaned duplicates from timed-out retries)
- 7.2 Make Create Commands Skip When Target Already Exists — MEDIUM-HIGH (prevents race conditions and wrapper if-exists checks)
- 7.3 Make Running the Same Command Twice Safe — MEDIUM-HIGH (prevents duplicate side effects on retry)
- 7.4 Prefer "Ensure State" Semantics Over Delta Application — MEDIUM (prevents errors when partial state is already applied)
- 7.5 Return the Same Output Shape Whether Acting or Skipping — MEDIUM (prevents downstream parser branching on did-anything-happen)
8. Command Structure — MEDIUM
- 8.1 Avoid Catch-All Handlers for Unknown Subcommands — MEDIUM (prevents locking in support for every typo forever)
- 8.2 Parse Flags in Any Position Relative to Subcommands — MEDIUM (prevents confusing errors when agents append flags)
- 8.3 Use a Consistent Resource-Verb Command Shape — MEDIUM (prevents re-reading help for every new subcommand)
- 8.4 Use Standard Flag Names — --help, --version, --verbose, --quiet — MEDIUM (prevents agents from guessing wrong flags)
---
References
1. https://clig.dev/ 2. https://devcenter.heroku.com/articles/cli-style-guide 3. https://jdx.dev/posts/2018-10-08-12-factor-cli-apps/ 4. https://www.gnu.org/prep/standards/html_node/Command_002dLine-Interfaces.html 5. https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html 6. https://no-color.org/ 7. https://rust-cli.github.io/book/index.html 8. https://jsonlines.org/
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Rule title matching the frontmatter title}
{1-3 sentences explaining WHY this matters in terms of agent behavior. What goes wrong without the rule? What cascade effect does the failure have on agent workflows? Name the specific failure mode, not just "it's bad practice." The reader should be able to predict what happens in an edge case even though this rule doesn't explicitly cover it.}
Incorrect ({short label of the problem}):
```{language} {Production-realistic code that uses a real CLI framework (commander, click, clap, cobra, etc.). Not strawman — a developer could write this in good faith. Keep it under 30 lines. Use comments sparingly to point out the cost.}
**Correct ({short label of the solution}):**
{Minimal-diff correct version. Same variable names, same structure, only the key insight changes. The reader should be able to diff incorrect vs correct in their head. Keep it under 30 lines.}
{Optional sections below, use only when genuinely needed:}
**Benefits:**
- {Observable benefit agents or operators will see}
- {Observable benefit agents or operators will see}
**When NOT to use this pattern:**
- {Exception with a concrete scenario, not "it depends"}
**Alternative ({short context label}):**
{Another valid approach, e.g. different library or language idiom}
Reference: [{Source title}]({https://source-url})
{
"version": "1.0.4",
"organization": "Agent-Friendly",
"technology": "CLI Design",
"discipline": "distillation",
"type": "code-quality",
"date": "April 2026",
"abstract": "Prescriptive design and review standards for command-line tools that AI agents and automation will invoke. Contains 45 rules across 8 categories, prioritized by blast radius from CRITICAL (non-interactive operation) through HIGH (help text design, error messages, destructive action safety, input handling) and MEDIUM-HIGH (output format, idempotency) to MEDIUM (command structure). Each rule explains the failure mode in concrete terms, provides production-realistic incorrect and correct examples in multiple languages (TypeScript, Python, Go, Rust, Bash), and links to authoritative sources including clig.dev, the Heroku CLI Style Guide, GNU Coding Standards, POSIX utility conventions, no-color.org, and the JSON Lines spec. Used both when building new CLIs and when reviewing existing ones for agent-friendliness regressions.",
"references": [
"https://clig.dev/",
"https://devcenter.heroku.com/articles/cli-style-guide",
"https://jdx.dev/posts/2018-10-08-12-factor-cli-apps/",
"https://www.gnu.org/prep/standards/html_node/Command_002dLine-Interfaces.html",
"https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html",
"https://no-color.org/",
"https://rust-cli.github.io/book/index.html",
"https://jsonlines.org/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Non-interactive Operation (interact)
Impact: CRITICAL Description: Interactive prompts, arrow-key menus, and TTY-only behaviours block agents entirely — nothing downstream in the agent's workflow is reachable if the CLI hangs on input.
2. Help Text Design (help)
Impact: HIGH Description: Agents learn a CLI from --help. Missing examples, unlayered top-level help, or help that requires a TTY to render leaves agents unable to discover what the CLI can do. One rule in this category — help-examples-in-help — is rated CRITICAL because its failure makes every downstream discovery attempt guesswork.
3. Error Messages (err)
Impact: HIGH Description: Bad errors cause retry loops that waste context and drift further from success. Good errors unblock agents immediately with a concrete, copy-pasteable next step.
4. Destructive Action Safety (safe)
Impact: HIGH Description: Without --dry-run and non-interactive confirmation bypass, agents either cannot use destructive commands at all or must avoid them out of caution — losing entire capability classes.
5. Input Handling (input)
Impact: HIGH Description: stdin, pipes, and flag parsing determine whether the CLI composes with other tools. Agents chain commands constantly, so input-side composability is load-bearing.
6. Output Format (output)
Impact: MEDIUM-HIGH Description: Structured success output lets agents extract IDs and chain commands. Decorative-only output (spinners, boxes, ANSI art) wastes tokens and forces brittle screen-scraping.
7. Idempotency & Retries (idem)
Impact: MEDIUM-HIGH Description: Agents retry often after transient failures. Non-idempotent commands cause double-effects the agent cannot detect, producing state drift that is expensive to unwind.
8. Command Structure (struct)
Impact: MEDIUM Description: Predictable resource-verb patterns and standard flag names let agents generalize from one subcommand to another without re-reading help for every invocation.
Include a Concrete Fix in Every Error Message
"Invalid input" is useless — the agent retries the same wrong thing with a different guess. "Invalid --env 'staing'. Valid values: staging, production, canary." tells the agent exactly how to fix it in one round. Every error should include the specific flag, the specific value, the specific fix, or the specific command to run next.
Incorrect (error names a problem but not a fix):
import click
@click.command()
@click.option('--env', required=True)
def deploy(env):
if env not in {'staging', 'production'}:
raise click.ClickException('Invalid environment.')
do_deploy(env)Correct (error names the problem AND the fix):
import click
VALID_ENVS = ('staging', 'production', 'canary')
@click.command()
@click.option('--env', required=True)
def deploy(env):
if env not in VALID_ENVS:
raise click.ClickException(
f"Invalid --env '{env}'. Valid values: {', '.join(VALID_ENVS)}.\n"
f" deploy --env staging"
)
do_deploy(env)Benefits:
- Agent fixes the input on the first retry, not the fifth
ClickExceptionexits with code 1 and prints to stderr automatically- Listing valid values turns the error into a mini-help message
Reference: clig.dev — Rewrite error messages for humans
Exit Fast on Missing Required Flags
Validation of required flags must happen at argument-parse time, before any setup runs. A CLI that spends 20 seconds pulling credentials and building a deploy plan, then errors out with "missing --tag," burns that 20 seconds on every agent retry. Flag validation is free; runtime setup is not. Fail at parse, not at execution.
Incorrect (validation happens mid-way through execution):
import { Command } from 'commander';
new Command()
.name('deploy')
.option('--env <env>')
.option('--tag <tag>')
.action(async ({ env, tag }) => {
// Expensive setup runs before --tag is even checked
const creds = await fetchCredentials(env); // 8s
const plan = await buildDeployPlan(env, creds); // 12s
if (!tag) {
throw new Error('missing tag'); // too late
}
await executeDeploy(plan, tag);
})
.parseAsync();Correct (requiredOption fails immediately during parse):
import { Command } from 'commander';
new Command()
.name('deploy')
.requiredOption('--env <env>', 'target environment')
.requiredOption('--tag <tag>', 'image tag to deploy')
.action(async ({ env, tag }) => {
// Parser already guaranteed env and tag are set
const creds = await fetchCredentials(env);
const plan = await buildDeployPlan(env, creds);
await executeDeploy(plan, tag);
})
.exitOverride((err) => {
if (err.code === 'commander.missingMandatoryOptionValue') {
console.error(`Error: ${err.message}`);
console.error(' deploy --env staging --tag v1.2.3');
process.exit(2);
}
throw err;
})
.parseAsync();Reference: clig.dev — Errors should be actionable
Include a Correct Example Invocation in Error Messages
When a flag is missing or wrong, the agent's next move is to re-read --help to find the right shape. Short-circuit that by including a complete, correct example invocation in the error itself. "Error: --tag required. Example: mycli deploy --env staging --tag v1.2.3" gives the agent everything it needs without another tool call.
Incorrect (error is the error text only):
use clap::Parser;
#[derive(Parser)]
struct Args {
#[arg(long)]
env: String,
#[arg(long)]
tag: String,
}
fn main() {
let args = Args::parse();
// clap default: "error: the following required arguments were not provided: --tag"
deploy(&args.env, &args.tag);
}Correct (error includes a complete example):
use clap::{Parser, CommandFactory};
#[derive(Parser)]
struct Args {
#[arg(long, help = "target environment (staging|production)")]
env: Option<String>,
#[arg(long, help = "image tag to deploy")]
tag: Option<String>,
}
fn main() {
let args = Args::parse();
let (Some(env), Some(tag)) = (args.env.as_deref(), args.tag.as_deref()) else {
eprintln!("Error: --env and --tag are required.");
eprintln!(" mycli deploy --env staging --tag v1.2.3");
eprintln!(" mycli deploy --env production --tag $(mycli build --output tag-only)");
std::process::exit(2);
};
deploy(env, tag);
}Benefits:
- Agent copies the example verbatim on retry
- Multiple examples teach variation without listing every flag
- No round-trip to
--helpafter a failed command
Reference: clig.dev — Errors should suggest fixes
Reserve Stack Traces for --debug Mode
A 40-line Python traceback dumped on a simple "file not found" wastes agent context and obscures the actual problem. Default errors should be one-line and actionable; stack traces belong in --debug mode or an opt-in $MYCLI_DEBUG=1 env var. This is not about hiding errors — it's about putting the signal first and the diagnostic detail second.
Incorrect (uncaught exceptions print full traceback):
import json
import sys
def load_config(path: str) -> dict:
with open(path) as f:
return json.load(f)
if __name__ == '__main__':
cfg = load_config(sys.argv[1])
print(cfg)
# Invoking with a missing file produces:
# Traceback (most recent call last):
# File "mycli.py", line 8, in <module>
# cfg = load_config(sys.argv[1])
# File "mycli.py", line 5, in load_config
# with open(path) as f:
# FileNotFoundError: [Errno 2] No such file or directory: 'missing.json'Correct (one-line error by default, traceback under --debug):
import json
import os
import sys
import traceback
def load_config(path: str) -> dict:
with open(path) as f:
return json.load(f)
def main() -> int:
try:
cfg = load_config(sys.argv[1])
print(cfg)
return 0
except FileNotFoundError as e:
print(f"Error: config file not found: {e.filename}", file=sys.stderr)
print(f" mycli --config ./config.json", file=sys.stderr)
if os.environ.get('MYCLI_DEBUG'):
traceback.print_exc(file=sys.stderr)
return 2
except json.JSONDecodeError as e:
print(f"Error: invalid JSON in config: {e.msg} (line {e.lineno})", file=sys.stderr)
if os.environ.get('MYCLI_DEBUG'):
traceback.print_exc(file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())When NOT to use this pattern:
- Unexpected internal errors (not caused by user input) should print a traceback and a bug-report URL — the user cannot fix them and the traceback helps the maintainer
Reference: clig.dev — Minimize noise in output
Use Distinct Non-Zero Exit Codes for Distinct Failures
Agents decide whether to retry based on exit code. Exit 0 means "done, continue." Exit 1 means "generic failure, retry with caution." Exit 2 traditionally means "usage error — don't retry, fix input." Exit 75 (EX_TEMPFAIL from sysexits.h) is the canonical "transient failure, retry with backoff" signal. A CLI that always exits 0 (or always exits 1) hides this signal, forcing agents to parse error text with regex.
Incorrect (every failure exits 1 or not at all):
package main
import (
"fmt"
"os"
)
func main() {
env := os.Getenv("ENV")
if env == "" {
fmt.Fprintln(os.Stderr, "missing env")
os.Exit(1) // agent retries, same result
}
if err := deploy(env); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1) // same code as usage error
}
}Correct (distinct codes for distinct failure classes):
package main
import (
"errors"
"fmt"
"os"
)
const (
ExitOK = 0
ExitFailure = 1
ExitUsage = 2
ExitTempFail = 75 // sysexits.h EX_TEMPFAIL: transient failure, retry-friendly
)
var ErrTransient = errors.New("transient upstream failure")
func main() {
env := os.Getenv("ENV")
if env == "" {
fmt.Fprintln(os.Stderr, "Error: ENV is required.")
fmt.Fprintln(os.Stderr, " ENV=staging mycli deploy")
os.Exit(ExitUsage) // agent: do not retry, fix input
}
if err := deploy(env); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
if errors.Is(err, ErrTransient) {
os.Exit(ExitTempFail) // agent: retry with backoff
}
os.Exit(ExitFailure)
}
}Alternative (bash with the same sysexits.h taxonomy):
The same rule applies to shell scripts. Bash has no native enums, but named constants at the top of the script achieve the same clarity — and more importantly, they make it obvious to the reader that 75 isn't an arbitrary number but EX_TEMPFAIL from /usr/include/sysexits.h.
#!/usr/bin/env bash
set -euo pipefail
# Exit codes. 0/1/2 follow POSIX/bash convention; 69/75 come from sysexits.h.
# Agents branch on these, so do not renumber.
readonly EX_OK=0
readonly EX_FAILURE=1
readonly EX_USAGE=2 # POSIX/getopt convention (sysexits.h defines 64)
readonly EX_UNAVAILABLE=69 # sysexits.h EX_UNAVAILABLE: service unavailable
readonly EX_TEMPFAIL=75 # sysexits.h EX_TEMPFAIL: transient failure, retry-friendly
main() {
local env="${ENV:-}"
if [[ -z $env ]]; then
echo "Error: ENV is required." >&2
echo " ENV=staging mycli deploy" >&2
exit "$EX_USAGE" # agent: do not retry, fix input
fi
if ! git fetch --prune origin 2>/dev/null; then
echo "Error: git fetch failed (network/upstream)." >&2
echo " Retry in a few seconds, or check VPN." >&2
exit "$EX_TEMPFAIL" # agent: retry with backoff
fi
deploy "$env" || exit "$EX_FAILURE"
exit "$EX_OK"
}
main "$@"Benefits:
- Agents branch on exit code without parsing text
- Code 2 (usage) signals "don't retry — fix the command"
- Code 75 (
EX_TEMPFAIL) signals "retry with backoff";EX_UNAVAILABLE(69) is the related "service unavailable" hard failure - Named constants (
$EX_TEMPFAILinstead of75) make scripts self-documenting and catch typos at read-time
Reference: FreeBSD sysexits.h — Preferable exit codes
Send Errors and Warnings to stderr, Not stdout
When an agent pipes mycli service list | jq '.[].name', error text mixed into stdout breaks the JSON parser downstream. The UNIX convention is strict: success data goes to stdout, everything else — errors, warnings, progress, debug — goes to stderr. An agent that redirects 2>/dev/null still gets clean data on stdout; an agent that wants both can redirect separately.
Incorrect (errors printed to stdout via console.log):
#!/usr/bin/env node
async function listServices() {
try {
const services = await api.listServices();
for (const s of services) {
console.log(JSON.stringify(s));
}
} catch (err) {
// Error message mixes into the JSON stream downstream
console.log(`Error: ${err.message}`);
process.exit(1);
}
}
listServices();Correct (data to stdout, errors and diagnostics to stderr):
#!/usr/bin/env node
async function listServices() {
try {
const services = await api.listServices();
for (const s of services) {
process.stdout.write(JSON.stringify(s) + '\n');
}
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exit(1);
}
}
listServices();Benefits:
mycli list | jq ...still works even on partial failuresmycli list 2>/dev/nullsilences warnings without losing datamycli list > data.json 2> errors.logcaptures them separately
Reference: clig.dev — Send output to stdout, messages to stderr
Include Copy-Pasteable Examples in Every --help
Agents pattern-match on examples far more reliably than on prose flag descriptions. A help text with "Options: --env <env>" forces the agent to guess the valid values, flag-argument shape, and quoting rules; an Examples section with mycli deploy --env staging --tag v1.2.3 teaches the full invocation in one line. Every --help — especially subcommand help — should end with 2-4 real, copy-pasteable examples that cover the common cases.
Incorrect (flag list only, no examples):
$ mycli deploy --help
Usage: mycli deploy [options]
Options:
--env <env> target environment
--tag <tag> image tag
--replicas <n> number of replicas
--force skip confirmation
-h, --help show helpCorrect (flag list plus a concrete Examples section):
$ mycli deploy --help
Usage: mycli deploy [options]
Deploy a service to a target environment.
Options:
--env <env> target environment (staging|production)
--tag <tag> image tag, e.g. v1.2.3 or "latest"
--replicas <n> number of replicas (default: 3)
--force skip confirmation prompt
-h, --help show this help and exit
Examples:
mycli deploy --env staging --tag v1.2.3
mycli deploy --env production --tag v1.2.3 --replicas 5
mycli deploy --env staging --tag "$(mycli build --output tag-only)" --force
See also:
mycli deploy list list recent deploys
mycli deploy rollback roll back the last deployBenefits:
- Agents learn flag shapes from examples instead of guessing
- Real values (
v1.2.3,staging) are far more useful than angle-bracket placeholders - Chained examples (
$(mycli build ...)) teach the agent how to compose subcommands
Reference: clig.dev — Lead with examples
List Both Short and Long Forms for Every Flag
When help shows only -e <env>, agents learn that shape and generate scripts using the single-letter form — then break when a new release adds --env and repurposes -e for something else. GNU coding standards and POSIX utility conventions both require that every short option has a corresponding long option for exactly this reason. Show both in --help so agents can prefer the long form, which is stable, self-documenting, and collision-resistant.
Incorrect (only short flag shown):
$ mycli deploy --help
Usage: mycli deploy [options]
Options:
-e <env> environment
-t <tag> image tag
-r <n> replicas
-f force
-h helpCorrect (short + long flag with default values):
$ mycli deploy --help
Usage: mycli deploy [options]
Options:
-e, --env <env> target environment (staging|production)
-t, --tag <tag> image tag (default: latest)
-r, --replicas <n> replica count (default: 3)
-f, --force skip confirmation
-h, --help show this help and exitBenefits:
- Agents prefer
--env stagingover-e staging— clearer in scripts, diff-friendly - Long flags are stable across versions; short flags can collide and get remapped
- Matches GNU/POSIX conventions that other tools rely on
When NOT to use this pattern:
- Commands with only a single flag (e.g.,
--help) don't need a short form - Experimental or debug-only flags can be long-only to discourage scripting against them
Structure Top-Level Help as a Navigational Index
Top-level help is not the manual — it's the table of contents. Agents use it to pick the right subcommand, then load that subcommand's own help for details. Putting flag details, environment variables, or configuration syntax at the top level doubles context cost for zero discovery benefit. The top level should list every subcommand with a one-line description, nothing more.
Incorrect (top-level help tries to cover everything):
$ mycli --help
mycli - production infrastructure CLI
SYNOPSIS
mycli [global-opts] <command> [command-opts] [args]
DESCRIPTION
mycli is a comprehensive production infrastructure management tool
supporting deployments, logs, secrets, and service configuration.
It uses the following environment variables:
MYCLI_TOKEN, MYCLI_REGION, MYCLI_PROFILE, ...
Configuration files are searched in the following order:
./mycli.yml, ~/.config/mycli/config.yml, /etc/mycli/config.yml
... (another 200 lines before getting to the subcommand list)Correct (top-level help is the TOC):
$ mycli --help
Usage: mycli <command> [options]
Production infrastructure CLI.
Commands:
deploy Deploy a service to an environment
logs Tail service logs
secret Manage encrypted secrets
service List and manage services
config Show or edit the mycli configuration
Run "mycli <command> --help" for details on a specific command.
Run "mycli config --help" for configuration and environment variables.Benefits:
- Agent consumes ~15 lines to navigate, not 200
- Each subcommand is one line — easy to scan and pattern-match
- Environment variables and config live in
mycli config --help, loaded only when relevant
Reference: clig.dev — Lead with common examples
Show Help When Invoked With Zero Arguments
Agents exploring a CLI will often try mycli with no args to see what happens. Three outcomes are possible: (1) silent exit — the agent has no idea what the CLI does; (2) run a default action — the agent doesn't learn the CLI's shape, and any side effect from the default is a surprise; (3) print a usage summary — the agent learns what's available. Option 3 is the only safe one. Top-level help with no args should also support -h, --help, and the bare help subcommand so the agent can find it however it guesses.
Incorrect (no args runs the default command):
import click
@click.command()
@click.argument('service', required=False)
def mycli(service):
# Running `mycli` with no args shows the last-used service's build status.
# Looks harmless, but agents can't tell what the tool does — they just see
# output and assume the CLI is working without ever reading --help.
if not service:
service = get_last_used_service()
show_build_status(service)
if __name__ == '__main__':
mycli()Correct (no args prints usage, exits 0):
import sys
import click
@click.group(invoke_without_command=True)
@click.pass_context
def mycli(ctx):
if ctx.invoked_subcommand is None:
# Safe default: show help and exit successfully
click.echo(ctx.get_help())
ctx.exit(0)
@mycli.command()
@click.argument('service')
def status(service):
show_build_status(service)
if __name__ == '__main__':
mycli()Benefits:
mycli,mycli -h,mycli --help, andmycli helpall work- Zero-arg invocation is safe: never runs side-effectful commands
- Exit code 0 signals "this is a successful discovery, not an error"
Reference: clig.dev — Display helpful output on zero-arg invocation
Every Subcommand Owns Its Own --help
Dumping the entire manual on mycli --help pollutes the agent's context with details about commands it isn't using. Agents pay for every token they read, so a monolithic help text forces them to carry 500+ lines of flag descriptions just to find the one flag they need. Layered help — top-level lists subcommands, each subcommand describes itself on demand — lets the agent load only the branch of the tree it is actually walking.
Incorrect (top-level --help dumps every subcommand's flags):
$ mycli --help
Usage: mycli <command> [options]
Commands:
deploy
--env <env> target environment
--tag <tag> image tag
--replicas <n> replica count
--force skip confirmation
... (20 more flags)
config
--file <path> config file
--format <fmt> output format
... (15 more flags)
logs
--tail <n> tail last N lines
--follow follow output
... (12 more flags)
# Every subcommand's flags, in one 400-line blobCorrect (top-level lists subcommands; details live in subcommand help):
$ mycli --help
Usage: mycli <command> [options]
Commands:
deploy Deploy a service to an environment
config Manage mycli configuration
logs Tail service logs
service List and manage services
Use "mycli <command> --help" for details on a command.
mycli deploy --help
mycli logs --help
$ mycli deploy --help
Usage: mycli deploy [options]
... (only the deploy-relevant flags)Benefits:
- Agent loads ~15 lines to pick a subcommand, then ~40 for that subcommand's detail — not 400
- Every subcommand's help is self-contained and copy-pasteable
- New subcommands don't inflate the top-level help page
Reference: clig.dev — Display helpful output on help
Suggest What to Run Next in Help and Success Output
Agents chain commands to complete tasks. A help or success message that ends with "See also: mycli deploy list" or "Next: mycli deploy verify --id dep_abc123" saves the agent from having to discover related commands by grepping the top-level help. Every success line and every help page should hint at the most likely next action. This is the single cheapest thing you can add to improve agent workflow quality.
Incorrect (help and success output are dead-ends):
$ mycli deploy --env staging --tag v1.2.3
Deploying...
Done.
$ mycli deploy --help
Usage: mycli deploy [options]
Options:
--env <env> environment
--tag <tag> tag
Examples:
mycli deploy --env staging --tag v1.2.3Correct (success output and help both suggest next actions):
$ mycli deploy --env staging --tag v1.2.3
deployed v1.2.3 to staging
url: https://staging.myapp.com
deploy_id: dep_abc123
duration: 34s
Next:
mycli deploy verify --id dep_abc123 verify the deploy
mycli logs --service myapp --tail 100 tail service logs
mycli deploy rollback --id dep_abc123 roll back if needed
$ mycli deploy --help
...
Examples:
mycli deploy --env staging --tag v1.2.3
See also:
mycli deploy list list recent deploys
mycli deploy rollback roll back the last deploy
mycli deploy verify verify deploy healthBenefits:
- Agents discover related commands without reading top-level help
- Success output doubles as a workflow tutorial
- The first command the agent runs teaches it two or three more
Reference: clig.dev — Suggest commands to run next
Make Create Commands Skip When Target Already Exists
A create command that errors on "already exists" forces the caller into one of two bad patterns: (a) wrap every call with try/catch and ignore the error — which also swallows real errors, or (b) first check if the resource exists and then create — which is racy. The cleanest design is to make create idempotent by default (or via an explicit --if-not-exists flag that agents reach for). Kubernetes apply, Terraform create_before_destroy, and useradd -f all follow this pattern.
Incorrect (create errors when resource already exists):
#!/usr/bin/env bash
set -euo pipefail
# mycli user:create alice --role admin
curl -fsS -X POST https://api.example.com/users \
-d '{"name":"alice","role":"admin"}' \
-H 'Content-Type: application/json'
# Retry after a network blip → HTTP 409 "already exists" → exit 1Correct (create is idempotent; second run prints "already exists"):
#!/usr/bin/env bash
set -euo pipefail
NAME=$1
ROLE=$2
existing=$(curl -fsS "https://api.example.com/users/${NAME}" 2>/dev/null || true)
if [[ -n $existing ]]; then
existing_role=$(echo "$existing" | jq -r '.role')
if [[ $existing_role == "$ROLE" ]]; then
echo "user ${NAME} already exists with role ${ROLE}"
exit 0
fi
echo "Error: user ${NAME} exists with different role '${existing_role}'." >&2
echo " mycli user:update ${NAME} --role ${ROLE}" >&2
exit 2
fi
curl -fsS -X POST https://api.example.com/users \
-d "{\"name\":\"${NAME}\",\"role\":\"${ROLE}\"}" \
-H 'Content-Type: application/json'
echo "user ${NAME} created with role ${ROLE}"Benefits:
- Retry on transient failure is always safe
- Same-config re-run is a no-op (not a duplicate, not an error)
- Different-config re-run errors with a concrete fix instead of silent drift
When NOT to use this pattern:
- When duplicate resources are semantically valid (e.g., "create log entry") — accept and explain explicitly via a different verb like
append
Reference: clig.dev — Make operations idempotent
Make Running the Same Command Twice Safe
Agents retry commands after transient failures — network blips, rate limits, timeouts. If the first run actually succeeded but the response was lost, the retry produces a second side effect: two rows inserted, two emails sent, two PRs opened. The fix is to define operations as "ensure this state" rather than "apply this delta." A re-run of an already-successful command becomes a no-op, and no manual reconciliation is needed.
Incorrect (each invocation creates a new resource):
import click
import requests
@click.command()
@click.argument('email')
def invite(email):
# Every invocation POSTs a new invite row
resp = requests.post('https://api.example.com/invites', json={'email': email})
resp.raise_for_status()
click.echo(f"invite sent to {email}: id={resp.json()['id']}")
# Agent retries after a timeout → email gets two invitesCorrect (second run detects the existing invite and no-ops):
import click
import requests
@click.command()
@click.argument('email')
def invite(email):
existing = requests.get(
'https://api.example.com/invites',
params={'email': email},
).json()
if existing:
click.echo(f"invite for {email} already exists: id={existing[0]['id']}")
return
resp = requests.post(
'https://api.example.com/invites',
json={'email': email},
headers={'Idempotency-Key': f'invite:{email}'},
)
resp.raise_for_status()
click.echo(f"invite sent to {email}: id={resp.json()['id']}")Benefits:
- Retrying after a timeout is safe — no duplicate invite
- The
Idempotency-Keyheader protects against races even during a single retry - Output is identical on both runs, so downstream parsing doesn't care which branch ran
Reference: clig.dev — Make idempotency a design goal
Accept User-Provided Names Instead of Auto-Generating IDs
When create, deploy, apply, or ANY state-changing command generates a random UUID server-side, a timed-out retry creates a second resource with a different ID — and the agent can't find the first one because it never saw the response. This is the #1 source of "ghost resources" in agent-driven ops. Accept a user-provided --name, --id, or --idempotency-key so that reruns target the same resource. If it already exists with the same config, the command is a no-op; if it exists with different config, you get a clear conflict error. This rule applies to deploy just as much as to create — a deploy that generates dep_abc123 randomly will create dep_xyz789 on retry, and the agent has no way to find the first.
Incorrect (server generates the ID; timed-out retry orphans resources):
#!/usr/bin/env bash
set -euo pipefail
# Agent: mycli volume:create --size 100
resp=$(curl -fsS --max-time 10 -X POST https://api.example.com/volumes \
-d '{"size":100}' || true)
if [[ -z $resp ]]; then
echo "timeout, retrying..." >&2
# BUG: the first call may have succeeded; we just didn't get the response.
# Retrying creates a second volume, and the agent can never find the first.
resp=$(curl -fsS -X POST https://api.example.com/volumes -d '{"size":100}')
fi
echo "$resp"Correct (caller provides a stable name; second call is a no-op):
#!/usr/bin/env bash
set -euo pipefail
NAME=$1 # e.g. "app-data-vol"
SIZE=$2
# GET by name first; creates are idempotent on name
if existing=$(curl -fsS "https://api.example.com/volumes/name/${NAME}" 2>/dev/null); then
existing_size=$(echo "$existing" | jq -r '.size')
if [[ $existing_size == "$SIZE" ]]; then
echo "$existing"
exit 0
fi
echo "Error: volume '${NAME}' exists with size ${existing_size}, requested ${SIZE}." >&2
exit 2
fi
curl -fsS -X POST https://api.example.com/volumes \
-d "{\"name\":\"${NAME}\",\"size\":${SIZE}}"Alternative (deploy command — `--idempotency-key` flag):
The same pattern applies to every state-changing verb, especially deploy. A deploy command that mints its own deployment ID turns every timed-out retry into a double-deploy. Accept a caller-supplied --idempotency-key (or --deployment-id) and make the same key always map to the same rollout.
import { Command } from 'commander';
import { randomUUID } from 'crypto';
new Command()
.name('deploy')
.requiredOption('--service <name>')
.requiredOption('--env <env>')
.requiredOption('--tag <tag>')
.option('--idempotency-key <key>', 'caller-supplied key for retry safety')
.action(async ({ service, env, tag, idempotencyKey }) => {
// If caller didn't pass a key, derive one deterministically from the
// input parameters. Retries with the same inputs hit the same deploy.
const key = idempotencyKey ?? `${service}:${env}:${tag}`;
// API honors Idempotency-Key on POST per RFC draft-ietf-httpapi-idempotency
const result = await api.deploy({ service, env, tag }, {
headers: { 'Idempotency-Key': key },
});
console.log(JSON.stringify({
deploy_id: result.id, // same on retry
idempotency_key: key, // echoed so the caller can audit
changed: result.changed, // true first time, false on retry
url: result.url,
}));
})
.parseAsync();Benefits:
- Timed-out retries target the same resource or deploy, not a new one
- No orphaned duplicates to clean up after flaky network conditions
- Client-chosen names/keys are also easier for agents to reference in later commands
- The pattern works for
create,deploy,apply,upload,send— any state-changing verb
Reference: AWS — Idempotent API requests with client tokens and IETF draft — Idempotency-Key HTTP header
Return the Same Output Shape Whether Acting or Skipping
When deploy creates a new deployment, it returns {id, url, env, tag}. When the same command is a no-op because the desired state is already present, it should return the SAME fields — populated from the existing resource — not a different "nothing to do" shape. Agents parsing the output can then use .id and .url in the next command without branching on "did something happen this time?"
Incorrect (different output shape for "already applied"):
#!/usr/bin/env node
const { Command } = require('commander');
new Command()
.name('apply')
.requiredOption('--name <name>')
.option('--json', 'output as JSON')
.action(async ({ name, json }) => {
const existing = await api.find(name);
if (existing) {
// Shape A: just a message
if (json) console.log(JSON.stringify({ skipped: true }));
else console.log(`${name} already exists — skipped`);
return;
}
const created = await api.create(name);
// Shape B: the real record
if (json) console.log(JSON.stringify({ id: created.id, url: created.url }));
else console.log(`created ${name}: ${created.id}`);
})
.parseAsync();
// Agent: `mycli apply --json | jq -r .id` → null on the second runCorrect (same fields either way; `changed` signals which branch ran):
#!/usr/bin/env node
const { Command } = require('commander');
new Command()
.name('apply')
.requiredOption('--name <name>')
.option('--json', 'output as JSON')
.action(async ({ name, json }) => {
const existing = await api.find(name);
const resource = existing ?? await api.create(name);
const changed = !existing;
if (json) {
console.log(JSON.stringify({
id: resource.id,
url: resource.url,
name: resource.name,
changed,
}));
} else {
console.log(`${changed ? 'created' : 'unchanged'} ${name}`);
console.log(`id: ${resource.id}`);
console.log(`url: ${resource.url}`);
}
})
.parseAsync();
// Agent: `mycli apply --json | jq -r .id` → real ID on every runBenefits:
- Downstream commands work on both first and subsequent runs
changed: true|falselets agents still branch on "did work happen" when needed- Matches Terraform/Ansible's
changedconvention
Prefer "Ensure State" Semantics Over Delta Application
"Apply these 5 migrations" is a delta — if some of them are already applied, the command errors out or double-applies. "Ensure the schema matches revision 42" is a state reconciliation — the command calculates the diff itself and applies only what's needed. State-based semantics are naturally idempotent, handle partial application gracefully, and let agents retry blindly. Terraform, Kubernetes, Ansible, Alembic's upgrade head, and every modern infrastructure tool use this pattern.
Incorrect (delta-based migration errors on partial state):
import click
from alembic import command
from alembic.config import Config
@click.command()
@click.argument('revisions', nargs=-1)
def migrate(revisions):
cfg = Config('alembic.ini')
# Caller must supply exactly the revisions not yet applied
for rev in revisions:
command.upgrade(cfg, rev)
# Retry after a network blip → errors: "revision already applied"Correct ("upgrade head" reconciles to the target, whatever's already applied):
import click
from alembic import command
from alembic.config import Config
@click.command()
@click.option('--target', default='head',
help='revision to reconcile to (default: head)')
def migrate(target):
cfg = Config('alembic.ini')
# Alembic computes the delta from current to target and applies only the gap
command.upgrade(cfg, target)
click.echo(f'schema reconciled to {target}')Benefits:
- Agent retries are always safe — reconciliation is the happy path
- Partial failures don't require manual rollback before retry
- The target state is the contract, not the steps to get there
When NOT to use this pattern:
- Destructive reconciliation (drop columns, delete rows) should still be explicit — use
--allow-destructiveor a separate command - Ordered, non-commutative operations (e.g., "run this exact set of scripts in order") are delta-shaped by design
Accept - as Filename for stdin and stdout
The UNIX convention is that - as a file argument means "stdin" for inputs and "stdout" for outputs. This is what makes curl https://... | tar -xf - work, and what makes jq . file.json and cat file.json | jq . - equivalent. A CLI that accepts only real paths forces pipeline composition through temporary files, which agents must then remember to clean up.
Incorrect (only accepts real file paths):
import click
import json
@click.command()
@click.argument('input_file', type=click.Path(exists=True, dir_okay=False))
def import_cmd(input_file):
with open(input_file) as f:
records = json.load(f)
for r in records:
save(r)
click.echo(f"imported {len(records)} records")
# Agent must stage a scratch file to pipe:
# mycli fetch --json > ./scratch-records.json && mycli import ./scratch-records.jsonCorrect (`-` reads from stdin):
import click
import json
import sys
@click.command()
@click.argument('input_file', type=click.File('r'), default='-')
def import_cmd(input_file):
records = json.load(input_file)
for r in records:
save(r)
click.echo(f"imported {len(records)} records")
# Now the agent can pipe directly:
# mycli fetch --json | mycli import -
# mycli import ./data.jsonBenefits:
click.File('r')handles both paths and-automatically- Pipeline composition without temp files or cleanup
- Consistent with
cat,jq,tar,curl, and every other UNIX tool
Reference: clig.dev — Support - for stdin/stdout
Accept Common Flags Through Environment Variables
Agents and scripts set environment variables once per session and then reuse them. For values that don't change between commands — region, profile, log format, API endpoint — accept an env-var fallback so the agent can set it once and omit the flag everywhere. The precedence must be: flag > environment variable > config file > built-in default, so an explicit flag always wins.
Incorrect (region must be repeated on every command):
import click
@click.command()
@click.option('--region', required=True)
@click.argument('service')
def status(region, service):
show_status(region, service)
# Agent must pass --region on every call:
# mycli status --region us-east-1 svc-a
# mycli status --region us-east-1 svc-b
# mycli status --region us-east-1 svc-cCorrect (MYCLI_REGION env var provides the fallback):
import click
@click.command()
@click.option('--region', envvar='MYCLI_REGION', required=True,
help='target region (env: MYCLI_REGION)')
@click.argument('service')
def status(region, service):
show_status(region, service)
# Agent sets MYCLI_REGION=us-east-1 once, then:
# mycli status svc-a
# mycli status svc-b
# mycli status svc-c --region eu-west-1 # flag overrides env varBenefits:
envvar='MYCLI_REGION'wires precedence automatically in click- Scripts set the env var at the top and forget about it
- Explicit
--regionstill wins when the agent needs a one-off override
When NOT to use this pattern:
- Secrets should NOT use env-var fallback (see
input-stdin-for-secrets) — they leak too easily - Values that change per command (like
--service) would mask bugs if env-var-sourced
Reference: clig.dev — Use environment variables for context-dependent config
Prefer Named Flags Over Positional Arguments
Positional arguments are opaque: mycli deploy staging v1.2.3 3 requires the agent to remember which slot means what. Named flags are self-documenting: mycli deploy --env staging --tag v1.2.3 --replicas 3 tells the agent (and the reader of a script) exactly what each value is. Positional args also break when you need to add an optional value later — flags allow additive growth. Reserve positional args for at most one primary operand (like cp source dest).
Incorrect (four positional arguments the agent must order correctly):
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) != 5 {
fmt.Fprintln(os.Stderr, "usage: deploy <env> <tag> <replicas> <region>")
os.Exit(2)
}
env, tag, replicas, region := os.Args[1], os.Args[2], os.Args[3], os.Args[4]
runDeploy(env, tag, replicas, region)
}
// Agent must memorize: deploy staging v1.2.3 3 us-east-1
// Swapping two positions silently works but deploys wrong thingCorrect (flags are self-documenting and order-independent):
package main
import (
"flag"
"fmt"
"os"
)
func main() {
env := flag.String("env", "", "target environment (staging|production)")
tag := flag.String("tag", "", "image tag to deploy")
replicas := flag.Int("replicas", 3, "replica count")
region := flag.String("region", "us-east-1", "target region")
flag.Parse()
if *env == "" || *tag == "" {
fmt.Fprintln(os.Stderr, "Error: --env and --tag are required.")
fmt.Fprintln(os.Stderr, " deploy --env staging --tag v1.2.3")
os.Exit(2)
}
runDeploy(*env, *tag, *replicas, *region)
}
// Agent invokes: deploy --env staging --tag v1.2.3
// Any order works, and --region has a sensible defaultWhen NOT to use this pattern:
- A single primary operand is fine as positional:
cp source dest,rm file,cat file - Variadic inputs like
rm file1 file2 file3are clearer positional thanrm --file a --file b
Reference: clig.dev — Prefer flags to arguments
Never Fall Back to a Prompt When a Flag Is Missing
TTY detection is imperfect — tmux, VS Code terminals, pty harnesses, and some CI runners all present as TTYs even when no human is watching. A CLI that falls back to a prompt on missing flags "because it looks interactive" can hang agents forever. The universally safe rule: if a required flag is missing AND the caller has not explicitly opted into prompts (via a flag like --interactive), error immediately with an example. Prompts must be explicitly opt-in, never implicit.
Incorrect (prompt fallback triggered by TTY detection alone):
import { Command } from 'commander';
import inquirer from 'inquirer';
new Command()
.name('deploy')
.option('--env <env>')
.action(async ({ env }) => {
// BUG: tmux and VS Code terminals both report isTTY=true;
// agent hangs here forever
if (!env && process.stdin.isTTY) {
const answers = await inquirer.prompt([
{ name: 'env', type: 'input', message: 'Environment?' },
]);
env = answers.env;
}
await runDeploy(env);
})
.parseAsync();Correct (prompt fallback requires explicit --interactive opt-in):
import { Command } from 'commander';
import inquirer from 'inquirer';
new Command()
.name('deploy')
.option('--env <env>')
.option('--interactive', 'prompt for missing values (TTY only)')
.action(async ({ env, interactive }) => {
if (!env) {
if (interactive && process.stdin.isTTY) {
const answers = await inquirer.prompt([
{ name: 'env', type: 'input', message: 'Environment?' },
]);
env = answers.env;
} else {
console.error('Error: --env is required.');
console.error(' deploy --env staging');
console.error(' deploy --interactive (then answer prompts)');
process.exit(2);
}
}
await runDeploy(env);
})
.parseAsync();Benefits:
- Default path never hangs, regardless of TTY detection accuracy
--interactiveis a clear, searchable signal in logs and history- Error message teaches the fix without re-reading
--help
Reference: clig.dev — Do not create implicit interactive behaviour
Accept Secrets Through stdin or File, Never as Flag Values
A secret passed as --token=sk_live_abc123 leaks into the process table (ps auxf), shell history (~/.bash_history), systemd journal, shell prompts, error logs, and sometimes CI/CD run logs. Secrets must come from either stdin (echo $TOKEN | mycli login --token-stdin) or a file (--token-file ~/.mycli/token). Never accept secrets as flag values — not even with a warning — because agents will follow the --help example and leak them.
Incorrect (token flag visible in ps and history):
import { Command } from 'commander';
new Command()
.name('login')
.requiredOption('--token <token>', 'API token')
.action(async ({ token }) => {
// `ps auxf` shows: mycli login --token sk_live_abc123
// `history` shows: mycli login --token sk_live_abc123
await saveCredentials(token);
})
.parseAsync();Correct (read from stdin or a file; flag value never accepted):
import { Command } from 'commander';
import { readFile } from 'node:fs/promises';
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString('utf8').trim();
}
new Command()
.name('login')
.option('--token-stdin', 'read token from stdin')
.option('--token-file <path>', 'read token from file')
.action(async ({ tokenStdin, tokenFile }) => {
let token: string;
if (tokenStdin) {
token = await readStdin();
} else if (tokenFile) {
token = (await readFile(tokenFile, 'utf8')).trim();
} else {
console.error('Error: pass --token-stdin or --token-file <path>.');
console.error(' echo "$TOKEN" | mycli login --token-stdin');
console.error(' mycli login --token-file ~/.mycli/token');
process.exit(2);
}
if (!token) {
console.error('Error: token is empty.');
process.exit(2);
}
await saveCredentials(token);
})
.parseAsync();Benefits:
- Nothing sensitive appears in
ps, history, or logs - Follows the same pattern as
docker login --password-stdin,gh auth login --with-token - File-based secrets can be managed by the OS secret store or a secrets manager
Reference: clig.dev — Never require secrets on the command line
Check for a TTY Before Prompting
Even when you legitimately need a prompt — a wizard mode, a credentials walkthrough, a password entry — the prompt must ALWAYS be guarded by isatty(stdin). Without the guard, the prompt blocks forever when piped, run in CI, or invoked by an agent — there is no one on the other end to answer it. process.stdin.isTTY (Node), sys.stdin.isatty() (Python), and term.IsTerminal(int(os.Stdin.Fd())) (Go) are one-line guards that turn "hang forever" into "fail fast with a usable error." Note: this rule is about guarding the prompt you chose to write; separately, prompts should never be the implicit fallback for a missing flag — see `input-no-prompt-fallback`.
Incorrect (prompt runs unconditionally, hangs when piped):
import click
@click.command()
def login():
# When piped or run by an agent, input() blocks forever
username = input('Username: ')
password = input('Password: ')
authenticate(username, password)
if __name__ == '__main__':
login()Correct (wizard mode is explicit + isatty-guarded; missing flags error fast):
import sys
import click
@click.command()
@click.option('--username', envvar='MYAPP_USERNAME')
@click.option('--password-file', type=click.Path(exists=True))
@click.option('--interactive', is_flag=True,
help='enter credentials interactively (requires a TTY)')
def login(username, password_file, interactive):
if interactive:
if not sys.stdin.isatty():
raise click.UsageError(
'--interactive requires a TTY on stdin.\n'
' myapp login --username alice --password-file ~/.myapp/pw'
)
username = username or click.prompt('Username')
password = click.prompt('Password', hide_input=True)
authenticate(username, password)
return
if not username or not password_file:
raise click.UsageError(
'Missing --username or --password-file.\n'
' myapp login --username alice --password-file ~/.myapp/pw\n'
' myapp login --interactive # enter credentials at a TTY prompt'
)
authenticate(username, read_password(password_file))Reference: clig.dev — Only use interactive elements if stdin is a TTY
Express Every Input as a Flag First
Agents cannot answer prompts, pick from arrow-key menus, or send keystrokes to a running process. A CLI that collects its primary inputs through interactive questions is fundamentally unusable headlessly — no amount of downstream quality matters. Flags-first design means: every input is a flag, missing required flags error immediately with a fix, and interactive prompts only appear behind an explicit --interactive opt-in (see `input-no-prompt-fallback`) — never as an implicit fallback.
Incorrect (inquirer prompts are the only way to pass input):
import { Command } from 'commander';
import inquirer from 'inquirer';
new Command()
.name('deploy')
.action(async () => {
// Agent runs `deploy` and hangs forever on this prompt
const { env, tag } = await inquirer.prompt([
{ name: 'env', type: 'list', choices: ['staging', 'production'] },
{ name: 'tag', type: 'input', message: 'Image tag?' },
]);
await runDeploy(env, tag);
})
.parseAsync();Correct (flags are required; errors are actionable; no implicit prompt):
import { Command } from 'commander';
const program = new Command()
.name('deploy')
.requiredOption('--env <env>', 'target environment (staging|production)')
.requiredOption('--tag <tag>', 'image tag to deploy')
.action(async ({ env, tag }) => {
await runDeploy(env, tag);
});
program.exitOverride((err) => {
if (err.code === 'commander.missingMandatoryOptionValue') {
console.error(`Error: ${err.message}`);
console.error(' deploy --env staging --tag v1.2.3');
console.error(' deploy --env production --tag $(mycli build --output tag-only)');
process.exit(2);
}
throw err;
});
await program.parseAsync();When NOT to use this pattern:
- REPLs and shells (
python,bash,irb) whose primary purpose IS interactive input - Scaffolding wizards invoked explicitly for their wizardness (
npm init,create-react-app) - TUI editors and dashboards (
htop,vim) where keyboard input is the feature - In all three cases, the tool should still accept flags for scripted use (
npm init -y,vim -c :q) but interactive input is the primary mode by design
Reference: clig.dev — Interactivity
Replace Arrow-Key Menus with Flag-Selected Choices
Arrow-key selection widgets (inquirer's list type, enquirer.Select, promptui.Select, blessed, terminal TUIs) require raw-mode keystrokes that agents cannot synthesize. The fix is: every menu choice must ALSO be reachable through a flag value, and the menu must appear only behind an explicit --interactive opt-in — not implicitly on missing flags. This aligns with `input-no-prompt-fallback`: tmux and pty harnesses report isTTY === true even when no human is watching, so TTY detection alone is not enough to gate a menu safely.
Incorrect (region selection is menu-only):
import inquirer from 'inquirer';
async function selectRegion(): Promise<string> {
// Agent cannot send arrow-key input to this prompt
const { region } = await inquirer.prompt({
name: 'region',
type: 'list',
message: 'Pick a region:',
choices: ['us-east-1', 'eu-west-1', 'ap-southeast-2'],
});
return region;
}Correct (flag is authoritative; menu only behind explicit --interactive):
import inquirer from 'inquirer';
const VALID_REGIONS = ['us-east-1', 'eu-west-1', 'ap-southeast-2'];
async function selectRegion(
flagValue: string | undefined,
interactive: boolean,
): Promise<string> {
if (flagValue) {
if (!VALID_REGIONS.includes(flagValue)) {
throw new Error(
`Invalid region '${flagValue}'. Valid: ${VALID_REGIONS.join(', ')}`
);
}
return flagValue;
}
if (!interactive || !process.stdin.isTTY) {
throw new Error(
`--region is required.\n` +
` Valid values: ${VALID_REGIONS.join(', ')}\n` +
` mycli deploy --region us-east-1\n` +
` mycli deploy --interactive # pick from a menu at a TTY`
);
}
const { region } = await inquirer.prompt({
name: 'region',
type: 'list',
choices: VALID_REGIONS,
});
return region;
}Reference: clig.dev — Interactivity fallback
Avoid Blocking on stdin When a TTY Is Attached
A CLI that reads stdin unconditionally will hang forever when invoked without a pipe: mycli import with no < file.json sits waiting for input the user never sends. Agents trying to discover the CLI see a frozen process and kill it after a timeout, losing the turn. Either require a file argument, OR check isTTY before attempting to read — if stdin is a TTY, print usage and exit instead of blocking.
Incorrect (reads stdin regardless of whether anything is piped):
#!/usr/bin/env node
const fs = require('fs');
async function main() {
// Hangs forever if run as `mycli import` with no pipe
const data = await fs.promises.readFile('/dev/stdin', 'utf8');
const records = JSON.parse(data);
await importRecords(records);
}
main();Correct (detect TTY, print usage instead of blocking):
#!/usr/bin/env node
const fs = require('fs');
async function main() {
const fileArg = process.argv[2];
if (fileArg && fileArg !== '-') {
return importRecords(JSON.parse(await fs.promises.readFile(fileArg, 'utf8')));
}
if (process.stdin.isTTY) {
console.error('Error: no input provided.');
console.error(' mycli import data.json');
console.error(' cat data.json | mycli import -');
process.exit(2);
}
const data = await fs.promises.readFile('/dev/stdin', 'utf8');
await importRecords(JSON.parse(data));
}
main();When NOT to use this pattern:
- Tools explicitly designed to read from stdin (e.g.,
jq,grep) can block on stdin as their primary mode — but they print usage to stderr when invoked interactively without a pipe.
Reference: clig.dev — Handle missing stdin gracefully
Support a --no-input Flag to Force Non-Interactive Mode
TTY detection is not always enough. Agents often run inside a terminal session (tmux, VS Code terminal, pty harness), so isatty() returns true even though no human is there to answer. --no-input is the explicit opt-out: "I am a script, never prompt, fail fast on missing values." clig.dev recommends this pattern specifically for this reason, and several large CLIs (npm, terraform) implement it verbatim.
Incorrect (no way to disable prompts from a harness):
import click
@click.command()
@click.option('--env')
def deploy(env):
if not env:
# Always prompts in a TTY, even for automated runs
env = click.prompt('Environment')
do_deploy(env)Correct (--no-input forces the error path):
import click
@click.command()
@click.option('--env')
@click.option('--no-input', is_flag=True, envvar='MYAPP_NO_INPUT',
help='Disable all prompts; fail on missing values.')
def deploy(env, no_input):
if not env:
if no_input:
raise click.UsageError(
'Missing --env. Valid values: staging, production.\n'
' myapp deploy --env staging'
)
env = click.prompt('Environment', type=click.Choice(['staging', 'production']))
do_deploy(env)Benefits:
- Agents set
MYAPP_NO_INPUT=1once and never hit a prompt again --no-inputis a well-known flag in the ecosystem (npm, terraform, gcloud)- Keeps the interactive UX intact for humans without a separate code path
Never Use Timed Prompts or Press-Any-Key Screens
A "press y within 30 seconds to continue" prompt will wait the full timeout on every agent invocation — the agent cannot send keystrokes during the countdown, so the CLI consumes 30 real seconds of wall-clock before moving on. Multiply by retries and you have a CLI that is technically working but unusable. Replace timeouts with an explicit --yes / -y flag that skips the confirmation entirely.
Incorrect (10-second countdown with keystroke poll):
package main
import (
"fmt"
"os"
"time"
)
func confirmRollback() bool {
fmt.Println("Rolling back in 10s. Press Ctrl-C to cancel, y to confirm now.")
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
// Agent cannot hit 'y' — the loop burns the full 10s every run
if keyPressed() == 'y' {
return true
}
time.Sleep(100 * time.Millisecond)
}
fmt.Fprintln(os.Stderr, "Timeout; aborting.")
return false
}Correct (explicit --yes flag, no implicit timer):
package main
import (
"flag"
"fmt"
"os"
)
func main() {
yes := flag.Bool("yes", false, "skip confirmation prompt")
flag.Parse()
if !*yes {
if !isTerminal(os.Stdin.Fd()) {
fmt.Fprintln(os.Stderr, "Error: --yes is required when stdin is not a TTY.")
fmt.Fprintln(os.Stderr, " mycli rollback --yes")
os.Exit(2)
}
if !promptYesNo("Roll back?") {
os.Exit(1)
}
}
doRollback()
}Reference: clig.dev — Confirm dangerous actions
Bound Default Output Size with --limit and --all
A list command that returns every record by default will dump 50,000 rows into the agent's context the first time it's invoked, exhausting the context budget on a discovery call. Bound the default (e.g., --limit 50) and let the user opt into the full set with --all or an explicit larger --limit. Document the limit in --help so agents know they are seeing a truncated view and can request more when they need it. gh, kubectl, and aws all default to small limits for this exact reason.
Incorrect (list returns everything by default):
import { Command } from 'commander';
new Command()
.name('list')
.option('--json', 'output as JSON')
.action(async ({ json }) => {
const services = await api.listAllServices(); // might be 50,000
if (json) {
console.log(JSON.stringify(services));
} else {
for (const s of services) {
console.log(`${s.name}\t${s.status}`);
}
}
})
.parseAsync();
// Agent: `mycli list` → 50,000 rows → context budget goneCorrect (default --limit 50; --all or larger --limit opts into more):
import { Command } from 'commander';
new Command()
.name('list')
.option('--limit <n>', 'max records to return (default: 50; use --all for all)', '50')
.option('--all', 'return every record — may be large')
.option('--json', 'output as NDJSON, one object per line')
.action(async ({ limit, all, json }) => {
const n = all ? Infinity : Number(limit);
const services = await api.listServices({ limit: n });
const truncated = !all && services.length === Number(limit);
for (const s of services) {
if (json) console.log(JSON.stringify(s));
else console.log(`${s.name}\t${s.status}`);
}
if (truncated) {
console.error(`(showing first ${limit}; pass --limit <n> or --all for more)`);
}
})
.parseAsync();
// Agent: `mycli list` → 50 rows + truncation hint on stderr
// Agent: `mycli list --limit 500` → 500 rows
// Agent: `mycli list --all --json` → streams everythingBenefits:
- Default invocation fits in the agent's context window every time
- Truncation hint on stderr teaches the agent how to ask for more
--allrequires an explicit opt-in for the expensive case — no accidents- Plays well with `output-ndjson-streaming` for the
--all --jsoncase
When NOT to use this pattern:
- Commands that always return a bounded small set by design (
mycli service show,mycli config list) — limits add noise without benefit - Count commands (
mycli service count) where a number IS the whole answer
Reference: gh CLI — List command pagination
Provide --json for Stable Machine-Readable Output
Human-readable output is a moving target — you'll want to add columns, change widths, and reformat as the tool evolves. Agents that scrape human output with regex will break on every cosmetic change. --json is the stable contract: a schema that only changes through explicit versioned deprecations. Every list, show, and status command should offer --json, emitting one object per record or a single top-level object.
Incorrect (only decorated human output; agents must regex-parse):
import click
from rich.table import Table
from rich.console import Console
@click.command()
def list_services():
services = api.list_services()
table = Table(title='Services')
table.add_column('Name')
table.add_column('Status')
table.add_column('Version')
for s in services:
table.add_row(s.name, s.status, s.version)
Console().print(table)
# Agent gets ANSI-decorated box drawing it has to strip and parseCorrect (human output stays pretty; --json is the stable contract):
import json
import click
from rich.table import Table
from rich.console import Console
@click.command()
@click.option('--json', 'as_json', is_flag=True, help='output as JSON')
def list_services(as_json):
services = api.list_services()
if as_json:
click.echo(json.dumps(
[{'name': s.name, 'status': s.status, 'version': s.version} for s in services],
indent=2,
))
return
table = Table(title='Services')
table.add_column('Name')
table.add_column('Status')
table.add_column('Version')
for s in services:
table.add_row(s.name, s.status, s.version)
Console().print(table)
# Agent pipes: mycli list --json | jq '.[] | select(.status=="failing") | .name'Benefits:
- Agents use
jqinstead of regex — faster, more reliable, more expressive - Human output can evolve freely without breaking scripts
- JSON schema can be versioned and documented separately
When NOT to use this pattern:
- Commands whose output is a single scalar (
mycli service count→ emit the number; JSON adds{"count": 42}ceremony without clarity) - Commands that emit binary data or raw file contents — use
--output <file>instead - Commands that already emit a stable structured format (YAML config dump) — re-serializing to JSON would lose fidelity
Reference: clig.dev — Implement a --json output mode
Return Chainable Values on Success, Not Just "Done"
"Done ✓" is decorative — it tells the agent nothing it can pass to the next command. On every state-changing operation (create, deploy, start), return the values the agent is most likely to need next: IDs, URLs, durations, counts. This turns a command from a dead-end into a workflow step that feeds naturally into its successors, without requiring a follow-up "where did it go?" lookup.
Incorrect (success output is a single word):
import { Command } from 'commander';
new Command()
.name('deploy')
.requiredOption('--env <env>')
.requiredOption('--tag <tag>')
.action(async ({ env, tag }) => {
const result = await api.deploy(env, tag);
console.log('Done.');
// Agent: "done with what? where is it? how do I verify?"
})
.parseAsync();Correct (success output includes every chainable value):
import { Command } from 'commander';
new Command()
.name('deploy')
.requiredOption('--env <env>')
.requiredOption('--tag <tag>')
.option('--json', 'output as JSON')
.action(async ({ env, tag, json }) => {
const start = Date.now();
const result = await api.deploy(env, tag);
const duration = Date.now() - start;
if (json) {
console.log(JSON.stringify({
deploy_id: result.id,
url: result.url,
env,
tag,
duration_ms: duration,
}));
return;
}
console.log(`deployed ${tag} to ${env}`);
console.log(`deploy_id: ${result.id}`);
console.log(`url: ${result.url}`);
console.log(`duration: ${(duration / 1000).toFixed(1)}s`);
console.log('');
console.log(`Next: mycli deploy verify --id ${result.id}`);
})
.parseAsync();Benefits:
- Agent extracts
deploy_idfrom the first command and uses it in the second - JSON mode supports
$(mycli deploy --json | jq -r .deploy_id)chaining - "Next:" hint teaches the most likely follow-up command
Stream Large Result Sets as NDJSON
A command that returns 10,000 records as a single top-level JSON array forces the agent to buffer the entire response before parsing — and then load the whole thing into context just to look at the first 50. NDJSON (newline-delimited JSON, one object per line, also known as JSON Lines) lets agents pipe through head -n 50, jq -c 'select(.status=="failing")', or awk-style tools without buffering the full set, and keeps structural validity at every line. Offer --ndjson (or make it the default for list commands with a --json that emits a top-level array for small results).
Incorrect (single-array JSON forces buffering the full result):
import click
import json
@click.command()
@click.option('--json', 'as_json', is_flag=True)
def list_deploys(as_json):
deploys = api.list_all_deploys() # might be 50,000 rows
if as_json:
click.echo(json.dumps([d.to_dict() for d in deploys]))
else:
for d in deploys:
click.echo(f'{d.id}\t{d.status}\t{d.env}')
# Agent: `mycli list --json | jq '.[0:10]'` → buffers 50,000 rows before `.[0:10]` runsCorrect (NDJSON streams one object per line; agent can `head` or filter live):
import click
import json
import sys
@click.command()
@click.option('--json', 'as_json', is_flag=True, help='output as NDJSON (one object per line)')
def list_deploys(as_json):
deploys = api.iter_deploys() # streaming iterator, no buffering
for d in deploys:
if as_json:
# Each line is a self-contained JSON object. BrokenPipe is normal
# when downstream `head` closes the pipe — catch and exit cleanly.
try:
sys.stdout.write(json.dumps(d.to_dict()) + '\n')
sys.stdout.flush()
except BrokenPipeError:
sys.exit(0)
else:
click.echo(f'{d.id}\t{d.status}\t{d.env}')
# Agent: `mycli list --json | head -n 10 | jq -c '.status'` → reads 10 rows and stops
# Agent: `mycli list --json | jq -c 'select(.status=="failing")'` → streams liveBenefits:
- Agent can
| head -n 50to bound context cost without server-side pagination jq -c/jq --slurpboth work; NDJSON is a superset of the array form- No memory pressure on the client — works even for unbounded result sets
- Each line is independently valid JSON, so a mid-stream failure doesn't corrupt earlier records
When NOT to use this pattern:
- For a single-object response (e.g.,
mycli deploy show <id>), emit the object directly — NDJSON is only for collections - Commands that return <100 records can also emit a top-level array; document which form is used per command
Reference: JSON Lines — Official format spec
Avoid Relying on Decorative Output to Convey State
Spinners, checkmark glyphs, progress bars, and box-drawing characters look great to humans but communicate nothing extra to agents — and can even be lost entirely when output is captured line-by-line (spinners overwrite the same terminal line). Every state the decoration conveys must ALSO appear as plain text: "done" in words, not just "✓"; "building (3/10)" in words, not just a progress bar. Treat decoration as a sugar layer on top of parseable text, never as the primary channel.
Incorrect (spinner is the only success indicator):
import ora from 'ora';
async function deploy() {
const spinner = ora('Deploying...').start();
try {
await api.deploy();
spinner.succeed(); // ✓ glyph only, no text
// Agent reading stdout via non-TTY capture sees: empty string
} catch (err) {
spinner.fail(); // ✗ glyph only
process.exit(1);
}
}
deploy();Correct (spinner is sugar; plain text is the primary channel):
import ora from 'ora';
async function deploy() {
// NO_COLOR governs color, not motion — use isTTY + CI detection instead
const useSpinner = process.stdout.isTTY && !process.env.CI;
const spinner = useSpinner ? ora('Deploying...').start() : null;
try {
const result = await api.deploy();
spinner?.stop();
// Plain text is always emitted, TTY or not
console.log(`deployed ${result.id} in ${result.duration}ms`);
} catch (err) {
spinner?.stop();
console.error(`Error: deploy failed: ${err.message}`);
console.error(' mycli deploy --tag v1.2.3 --debug # for details');
process.exit(1);
}
}
deploy();Benefits:
- Agent capture (
mycli deploy 2>&1 | tee run.log) always contains the state - Humans still get the spinner when running interactively
- Line-based logs from CI/test runners correctly record success vs failure
Reference: clig.dev — Don't animate in non-TTY
Emit One Record Per Line for Grep-Able Human Output
Table borders (+---+---+), multi-line cells, and word-wrapped columns break grep, awk, and cut. Agents that don't have --json fall back to line-based parsing, and line-based parsing only works when every line is a self-contained record. Emit human-readable output as one record per line with stable column positions or a consistent separator, even when the record is long. Pretty tables belong in a --pretty mode, not the default.
Incorrect (multi-line table with borders and wrapping):
$ mycli service list
+----------+--------+-------------------------------------+
| Name | Status | Description |
+----------+--------+-------------------------------------+
| api | up | REST API for the public product, |
| | | handles /v1 and /v2 endpoints |
| worker | up | Background job processor |
| billing | down | Payment and subscription service |
+----------+--------+-------------------------------------+
# `mycli service list | grep down` matches only one line of a multi-line cellCorrect (one record per line, stable field order):
$ mycli service list
NAME STATUS DESCRIPTION
api up REST API for the public product; handles /v1 and /v2 endpoints
worker up Background job processor
billing down Payment and subscription service
# `mycli service list | grep down` matches the full billing record
# `mycli service list | awk '$2=="down"' | cut -f1` extracts failing service namesImplementation:
import click
@click.command()
def list_services():
services = api.list_services()
# Headers (single line)
click.echo('NAME\tSTATUS\tDESCRIPTION')
for s in services:
# Each record on exactly one line; newlines in description replaced
desc = s.description.replace('\n', '; ')
click.echo(f'{s.name}\t{s.status}\t{desc}')Benefits:
- Every UNIX line-based tool (grep, awk, cut, sort, uniq, wc) works out of the box
- Stable tab-separated output can be imported into spreadsheets and data tools
- Agent parsing is
.split('\t')— no state machine needed
Reference: Heroku CLI Style Guide — Output handling
Disable ANSI Color When NO_COLOR or Non-TTY
ANSI escape sequences (\x1b[31m) confuse agents that pattern-match on output — a regex for Error: won't match \x1b[31mError:\x1b[0m. Three signals mean "don't emit color": the NO_COLOR env var is set to a non-empty value (per the no-color.org spec — any non-empty value disables color), the destination stream is not a TTY (piped or redirected), or the user passed --no-color. Check all three against whichever stream you're writing color to (stderr for error color, stdout for data color).
Incorrect (always emits color regardless of context):
package main
import (
"fmt"
"os"
)
const (
red = "\x1b[31m"
reset = "\x1b[0m"
)
func main() {
if err := run(); err != nil {
// Agent piping to grep sees: \x1b[31mError: ...\x1b[0m
fmt.Fprintf(os.Stderr, "%sError: %s%s\n", red, err, reset)
}
}Correct (honor NO_COLOR, --no-color, and isTTY):
package main
import (
"flag"
"fmt"
"os"
"golang.org/x/term"
)
func useColor(noColorFlag bool) bool {
if noColorFlag {
return false
}
if v, ok := os.LookupEnv("NO_COLOR"); ok && v != "" {
return false // no-color.org: set to any non-empty value disables color
}
// Checking stderr because this program colorizes errors.
// Check stdout instead when colorizing data output.
return term.IsTerminal(int(os.Stderr.Fd()))
}
func main() {
noColor := flag.Bool("no-color", false, "disable ANSI color output")
flag.Parse()
colorize := useColor(*noColor)
if err := run(); err != nil {
if colorize {
fmt.Fprintf(os.Stderr, "\x1b[31mError: %s\x1b[0m\n", err)
} else {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
}
}
}Benefits:
- Non-empty-value
NO_COLORcheck matches the spec at no-color.org - Agents regex-match
Error:without stripping escape sequences first - Works automatically in CI,
| grep,| tee log.txt, and redirected runs
Reference: no-color.org — NO_COLOR specification
Require Typing the Resource Name for Irreversible Actions
y/N prompts are easy to muscle-memory past, and easy for an agent to accidentally satisfy with a badly-parameterized --yes flag. For severe, irreversible actions — delete production database, drop table, destroy cluster — require the caller to type (or pass via --confirm=) the resource name. This escalates friction to match blast radius and makes accidental confirmation nearly impossible.
Incorrect (simple yes/no for a production-db drop):
import { Command } from 'commander';
import inquirer from 'inquirer';
new Command()
.name('db:drop')
.requiredOption('--database <name>')
.action(async ({ database }) => {
const { ok } = await inquirer.prompt({
name: 'ok', type: 'confirm', message: `Drop ${database}?`,
});
if (ok) await dropDatabase(database);
})
.parseAsync();Correct (require typing the database name, verified against --database):
import { Command } from 'commander';
import inquirer from 'inquirer';
new Command()
.name('db:drop')
.requiredOption('--database <name>', 'database to drop')
.option('--confirm <name>', 'must match --database for severe actions')
.action(async ({ database, confirm }) => {
if (confirm !== database) {
if (!process.stdin.isTTY) {
console.error(`Error: --confirm='${database}' is required to drop '${database}'.`);
console.error(` mycli db:drop --database ${database} --confirm ${database}`);
process.exit(2);
}
const { typed } = await inquirer.prompt({
name: 'typed', type: 'input',
message: `Type the database name to confirm drop:`,
});
if (typed !== database) {
console.error('Aborted: name did not match.');
process.exit(1);
}
}
await dropDatabase(database);
})
.parseAsync();When NOT to use this pattern:
- Routine writes (create, update, small-file delete) are safer with a simple
--yes - Read-only commands never need confirmation at all
Reference: clig.dev — Severe actions require typing the resource name
Design Multi-Step Commands for Crash-Only Recovery
A multi-step command (copy files, run migrations, deploy) that partially fails and then needs a manual mycli reset before retry is a trap for agents — they will retry blindly and get the same error forever. Design for crash-only recovery: either the next invocation can resume from wherever the last one left off, OR it can safely start over from scratch without user intervention. Never require a manual cleanup between runs.
Incorrect (partial failure leaves a lock file that blocks retries):
#!/usr/bin/env bash
set -euo pipefail
LOCK=/var/run/mycli-deploy.lock
if [[ -f $LOCK ]]; then
echo "Error: deploy in progress (lock file exists)" >&2
echo "Run 'mycli deploy unlock' to recover" >&2
exit 1
fi
touch "$LOCK"
step_1_copy_files
step_2_run_migration # fails here
step_3_restart_service
rm -f "$LOCK"Correct (lock file uses PID; stale locks are ignored on retry):
#!/usr/bin/env bash
set -euo pipefail
LOCK=/var/run/mycli-deploy.lock
# If lock exists but owner is dead, clear it — the previous run crashed
if [[ -f $LOCK ]]; then
pid=$(cat "$LOCK")
if kill -0 "$pid" 2>/dev/null; then
echo "Error: deploy $pid already running" >&2
exit 2
fi
echo "Clearing stale lock from dead pid $pid" >&2
rm -f "$LOCK"
fi
echo $$ > "$LOCK"
trap 'rm -f "$LOCK"' EXIT
# Each step is idempotent, so retrying from scratch is safe
step_1_copy_files # uses rsync, overwrite-safe
step_2_run_migration # checks "migration already applied" and skips
step_3_restart_service # safe to call on a running serviceBenefits:
- Agent can retry after any failure without human intervention
- Stale locks never block indefinitely — they clear on next run
- Each step checks desired state, so re-running is a no-op when already done
Reference: clig.dev — Crash-only design
Provide --dry-run for Every Destructive Command
Agents explore CLIs by trying commands, and destructive commands without a --dry-run flag force a binary choice: never use the command (losing a capability), or run it with full effect and hope (losing data). --dry-run (or -n) shows exactly what WOULD change — which files, which resources, which API calls — without actually making the change. It is the difference between a command an agent can safely experiment with and one it must avoid.
Incorrect (no preview mode at all):
#!/usr/bin/env bash
set -euo pipefail
# cleanup-orphans.sh — removes untagged container images
for image in $(docker images --filter "dangling=true" -q); do
docker rmi "$image"
done
echo "Cleanup complete."Correct (--dry-run shows the plan without executing):
#!/usr/bin/env bash
set -euo pipefail
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--dry-run) DRY_RUN=1; shift ;;
-h|--help)
cat <<EOF
Usage: cleanup-orphans [--dry-run]
--dry-run, -n show what would be removed without removing it
Examples:
cleanup-orphans --dry-run
cleanup-orphans
EOF
exit 0 ;;
*) echo "Error: unknown flag '$1'" >&2; exit 2 ;;
esac
done
mapfile -t orphans < <(docker images --filter "dangling=true" -q)
if [[ ${#orphans[@]} -eq 0 ]]; then
echo "No orphaned images."
exit 0
fi
if (( DRY_RUN )); then
echo "Would remove ${#orphans[@]} orphaned images:"
printf ' %s\n' "${orphans[@]}"
exit 0
fi
for image in "${orphans[@]}"; do
docker rmi "$image"
done
echo "Removed ${#orphans[@]} orphaned images."Benefits:
- Agents preview cleanup before committing — no irreversible surprises
- Same code path for dry-run and real run, reducing divergence bugs
--dry-runoutput is the audit trail of what the real run will do
When NOT to use this pattern:
- Commands whose action IS the preview — linters, validators,
terraform plan(the whole command is already a dry-run) - Read-only commands (list, show, get) — there is nothing destructive to preview
- Commands where "what would change" cannot be computed without actually doing the work (e.g., stream transformers that read stdin byte-by-byte)
Reference: clig.dev — Provide a --dry-run flag
Provide --yes or --force to Skip Confirmation Prompts
A CLI that always prompts before destructive actions is safe for humans but unusable for agents — the agent has no keyboard. --yes (often aliased -y or --force) skips the confirmation and proceeds directly. Keep the interactive default safe; keep the non-interactive path fast. npm, terraform, apt, rm, and gh all implement this exact pattern. For extra strictness against harnesses that falsely report TTY, see `input-no-prompt-fallback`, which requires --interactive to even reach a prompt.
Incorrect (confirmation is the only safety mechanism):
import click
@click.command()
@click.argument('service')
def delete(service):
if not click.confirm(f"Really delete service '{service}'?"):
click.echo("Aborted.")
return
api.delete_service(service)
click.echo(f"Deleted {service}.")Correct (--yes skips the prompt; default stays safe for humans):
import sys
import click
@click.command()
@click.argument('service')
@click.option('-y', '--yes', is_flag=True, help='skip confirmation prompt')
def delete(service, yes):
if not yes:
if not sys.stdin.isatty():
raise click.UsageError(
f"Refusing to delete '{service}' without --yes in non-interactive mode.\n"
f" mycli delete {service} --yes"
)
if not click.confirm(f"Really delete service '{service}'?"):
click.echo("Aborted.")
return
api.delete_service(service)
click.echo(f"Deleted {service}.")Benefits:
- Human workflow unchanged:
mycli delete myappstill prompts - Agent workflow works:
mycli delete myapp --yesproceeds directly - Non-TTY + no
--yes= explicit refusal (safer than silent delete)
Reference: clig.dev — Always allow -f or --force to skip confirmation
Exit Successfully When Delete Targets Are Already Gone
Agents retry commands on transient failures. If a delete command errors out because the resource is already gone, the retry will fail forever — the resource can never become "not yet deleted" again. The correct behavior for a delete is: "if the target doesn't exist, exit successfully with a message saying so." This turns "delete" into "ensure not present," which is naturally idempotent.
Incorrect (delete errors when resource is already gone):
import click
import requests
@click.command()
@click.argument('service_id')
def delete(service_id):
resp = requests.delete(f'https://api.example.com/services/{service_id}')
if resp.status_code == 404:
raise click.ClickException(f"service '{service_id}' not found")
if resp.status_code != 200:
raise click.ClickException(f"delete failed: {resp.text}")
click.echo(f"deleted {service_id}")
# Agent retries after a network blip — second run errors with "not found"Correct (delete exits 0 when target is already gone):
import sys
import click
import requests
@click.command()
@click.argument('service_id')
def delete(service_id):
resp = requests.delete(f'https://api.example.com/services/{service_id}')
if resp.status_code == 200:
click.echo(f"deleted {service_id}")
sys.exit(0)
if resp.status_code == 404:
click.echo(f"{service_id} already absent", err=True)
sys.exit(0) # idempotent success, not an error
raise click.ClickException(f"delete failed: {resp.status_code} {resp.text}")Benefits:
- Safe to retry blindly — second run is a no-op
- Distinguishes "already gone" (stderr note) from "deleted just now" (stdout)
- Matches the semantics of
rm -f,kubectl delete --ignore-not-found,terraform destroy
Reference: clig.dev — Make operations idempotent where possible
Never Prompt When --no-input Is Set
--no-input is the explicit signal "I am a script, never prompt" (defined in `interact-no-input-flag`). Destructive commands that would normally prompt for confirmation must respect this — not by silently proceeding (that's dangerous), and not by falling through to the prompt anyway (that's a hang), but by erroring immediately with "requires --yes." The guardrail stays intact, the hang doesn't happen, and the agent gets a clear fix.
Incorrect (--no-input silently bypasses confirmation):
package main
import (
"flag"
"fmt"
"os"
)
func main() {
var noInput = flag.Bool("no-input", false, "disable prompts")
var yes = flag.Bool("yes", false, "skip confirmation")
flag.Parse()
if !*yes && !*noInput {
if !confirm("Really delete?") {
os.Exit(1)
}
}
// BUG: --no-input alone bypasses confirmation entirely
doDelete()
fmt.Println("deleted")
}Correct (--no-input requires --yes for destructive actions):
package main
import (
"flag"
"fmt"
"os"
)
func main() {
var noInput = flag.Bool("no-input", false, "disable prompts; fail on missing values")
var yes = flag.Bool("yes", false, "skip confirmation")
flag.Parse()
if !*yes {
if *noInput || !isTerminal(os.Stdin.Fd()) {
fmt.Fprintln(os.Stderr, "Error: --yes is required in non-interactive mode.")
fmt.Fprintln(os.Stderr, " mycli delete --yes")
os.Exit(2)
}
if !confirm("Really delete?") {
os.Exit(1)
}
}
doDelete()
fmt.Println("deleted")
}Benefits:
--no-inputnever silently proceeds past a guardrail- Agent gets a clear "add --yes" fix instead of a hang or a surprise delete
- Non-TTY detection is folded into the same check for consistency
Reference: clig.dev — Interactivity and scripts
Parse Flags in Any Position Relative to Subcommands
Agents often construct a command and then append flags to it: mycli deploy --env staging becomes mycli deploy --env staging --debug. If the parser only accepts flags in a specific position (e.g., global flags before the subcommand, local flags after), then mycli --debug deploy --env staging and mycli deploy --env staging --debug behave differently — and the one that fails produces a cryptic error. Parse flags in any position; GNU getopt does this by default, and it's what agents expect.
Incorrect (POSIXLY_CORRECT parser rejects flags after the subcommand):
// POSIX-strict: option processing stops at first non-option argument
int main(int argc, char **argv) {
int opt;
while ((opt = getopt(argc, argv, "+v")) != -1) {
if (opt == 'v') verbose = 1;
}
// `mycli deploy -v` — the -v is never seen because "deploy" stopped parsing
handle_subcommand(argc - optind, argv + optind);
}Correct (GNU getopt permutes argv so flags work in any position):
// GNU getopt: by default, permutes argv so all options move to the front
int main(int argc, char **argv) {
int opt;
while ((opt = getopt(argc, argv, "v")) != -1) {
if (opt == 'v') verbose = 1;
}
// `mycli deploy -v`, `mycli -v deploy`, and `mycli deploy --env staging -v`
// all set verbose=1 correctly
handle_subcommand(argc - optind, argv + optind);
}Or in Python click (subcommand groups propagate global flags automatically):
import click
@click.group()
@click.option('-v', '--verbose', is_flag=True)
@click.pass_context
def cli(ctx, verbose):
ctx.ensure_object(dict)
ctx.obj['verbose'] = verbose
@cli.command()
@click.option('--env', required=True)
@click.pass_context
def deploy(ctx, env):
if ctx.obj['verbose']:
click.echo(f"deploying to {env}...")
do_deploy(env)
# All three work:
# mycli -v deploy --env staging
# mycli deploy -v --env staging
# mycli deploy --env staging -vBenefits:
- Agents append flags without worrying about position
-vfor verbose works on any subcommand without being redeclared- Matches the behavior of nearly every modern CLI
Avoid Catch-All Handlers for Unknown Subcommands
If mycli foo quietly falls through to some default handler when foo isn't a known subcommand, you've accidentally promised to keep that behavior working forever. You've also made error messages ambiguous — did the agent mean to run foo or typo food? The right behavior is: fail immediately on unknown subcommands with a "did you mean..." suggestion. This lets you add new subcommands without worrying about collision, and gives agents a fast, unambiguous fix.
Incorrect (unknown subcommands silently fall through to a default):
import { Command } from 'commander';
const program = new Command('mycli');
program.command('service <op>').action(handleService);
program.command('logs').action(handleLogs);
program
.action((cmd) => {
// Catch-all: agent typos `mycli deplpy` → this runs the default "apply"
console.log('applying default config...');
applyDefault();
})
.parseAsync();Correct (unknown subcommand errors with a suggestion):
import { Command } from 'commander';
import { closest } from 'fastest-levenshtein';
const program = new Command('mycli');
program.command('service <op>').action(handleService);
program.command('logs').action(handleLogs);
program.command('deploy').action(handleDeploy);
program.exitOverride();
try {
await program.parseAsync();
} catch (err: any) {
if (err.code === 'commander.unknownCommand') {
const attempted = process.argv[2];
const known = ['service', 'logs', 'deploy'];
const suggestion = closest(attempted, known);
console.error(`Error: unknown command '${attempted}'.`);
console.error(` Did you mean '${suggestion}'?`);
console.error(` mycli --help`);
process.exit(2);
}
throw err;
}Benefits:
- New subcommands can be added without worrying about masking existing behavior
- Typos get a one-line fix instead of a surprise side effect
exit code 2tells the agent "don't retry — fix the command"
Reference: clig.dev — Never add catch-all subcommands
Use a Consistent Resource-Verb Command Shape
Once an agent learns mycli service list, it should be able to guess mycli deploy list, mycli config list, and mycli secret list without re-reading --help. Consistent resource verb (or the equivalent verb resource) structure lets agents generalize one subcommand's shape to all of them — saving tokens, reducing latency, and avoiding guessing-induced errors. Kubernetes (kubectl get/describe/delete/apply <resource>), Heroku, and gh all follow this pattern.
Incorrect (each subcommand invents its own shape):
mycli list-services # verb-resource with dash
mycli config-get KEY # verb-resource for config
mycli deploy rollback # resource-verb for deploys
mycli rm-secret NAME # abbreviated verb-resource
mycli logs tail --service api # singular-plural inconsistencyCorrect (uniform resource-verb structure across all commands):
mycli service list
mycli service get <name>
mycli service delete <name>
mycli config list
mycli config get <key>
mycli config set <key> <value>
mycli deploy list
mycli deploy rollback <id>
mycli secret list
mycli secret delete <name>
mycli logs tail --service apiImplementation (commander.js nested commands):
import { Command } from 'commander';
const program = new Command('mycli');
const service = program.command('service');
service.command('list').action(listServices);
service.command('get <name>').action(getService);
service.command('delete <name>').action(deleteService);
const config = program.command('config');
config.command('list').action(listConfig);
config.command('get <key>').action(getConfig);
config.command('set <key> <value>').action(setConfig);
program.parse();Benefits:
- Agents learn one pattern and apply it across all resources
- New commands slot into the existing shape, no special-casing
- Tab-completion and
--helpare predictable at every level
Reference: Heroku CLI Style Guide — Naming conventions
Use Standard Flag Names — --help, --version, --verbose, --quiet
Every major CLI uses the same names for the same concepts. Agents learn these from their pre-training on thousands of existing tools and will try them first, regardless of what your --help says. Rebinding -v to mean --version (when the rest of the world uses -v for --verbose) causes agents to pass -v expecting verbosity and get version output instead — a silent and confusing failure. Reserve the standard short flags for their standard meanings, and invent new ones only for concepts not already in the ecosystem.
Incorrect (non-standard short-flag bindings):
import click
@click.command()
@click.option('-v', is_flag=True, help='print version') # conflicts with convention
@click.option('-q', help='queue name') # conflicts with --quiet
@click.option('-f', help='config file') # conflicts with --force
@click.option('-d', is_flag=True, help='delete') # conflicts with --debug
def mycli(v, q, f, d):
...Correct (standard short flags retain their standard meanings):
import click
@click.command()
@click.version_option(version='1.2.3') # -V / --version
@click.option('-v', '--verbose', is_flag=True) # -v → verbose
@click.option('-q', '--quiet', is_flag=True) # -q → quiet
@click.option('-f', '--force', is_flag=True) # -f → force
@click.option('-n', '--dry-run', is_flag=True) # -n → dry-run
@click.option('--queue', help='queue name') # custom concept, long-only
@click.option('--config-file', type=click.Path()) # custom concept, long-only
def mycli(verbose, quiet, force, dry_run, queue, config_file):
...Standard short flags to respect:
| Short | Long | Meaning |
|---|---|---|
-h | --help | Show help |
-V | --version | Show version (capital V; some tools use -v) |
-v | --verbose | Verbose output |
-q | --quiet | Suppress non-essential output |
-f | --force | Skip confirmations |
-n | --dry-run | Preview without applying |
-o | --output | Output file or format |
-y | --yes | Assume yes to prompts |
Benefits:
- Agents guess right on the first try
- Scripts that composed well with other tools continue to compose with yours
- New domain-specific flags stay as long-form to avoid collisions
Reference: GNU Coding Standards — Standard Command-Line Options
Related skills
FAQ
What does cli-for-agents do?
cli-for-agents: A skill for development. This provides functionality for development workflows.
When should I use cli-for-agents?
When you need to use cli-for-agents for development tasks, or when cli-for-agents: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
cli-for-agents.