Guide: The No-Args Entry Point
The principle: A multi-command CLI must exit 0 and show its root help when invoked with no arguments. This is the agent's first-contact probe — every agent-compatibility feature is unreachable if the entry point fails.
Agents do not start with --help. They run the bare binary first. It is cheaper, faster, and tells them whether the tool is accessible before committing tokens to per-command discovery. A non-zero exit on that probe short-circuits the entire session. The agent logs a failure, may retry, and may give up — never reaching the --schema flag, the JSON output mode, or the structured error codes that the CLI author built.
The Discovery Gap
A CLI can invest in advanced agent features and still be unreachable. The bare-invocation probe is the first thing an agent runs — if it fails, nothing else matters:
$ poly
usage: poly [-h] [--schema] [--output FORMAT] [--json] [-v] COMMAND ...
poly: error: the following arguments are required: COMMAND
exit code: 2
poly has --schema, --output FORMAT, and --json — none of it reachable. Contrast with:
$ polymarket
Polymarket CLI
Usage: polymarket [OPTIONS] <COMMAND>
Commands:
markets Interact with markets
events Interact with events
clob Interact with the CLOB
wallet Manage wallet and authentication
...
Options:
-o, --output <OUTPUT> [default: table] [possible values: table, json]
-h, --help
polymarket has no --schema flag. Agents can still discover and use it because the entry point works.
Discovery layers are orthogonal. Both dimensions matter. The entry point is the prerequisite.
The Two argparse Anti-Patterns
add_subparsers() defaults to required=False. On bare invocation, argparse returns silently with no output — the caller receives Namespace(command=None) and no error. This is itself a failure for agents, who expect a command list on stdout.
Anti-pattern 1 — default: returns silently with no output
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command") # required=False by default
args = parser.parse_args()
# bare invocation: parse_args() returns Namespace(command=None), prints nothing
# calling args.func(args) here raises AttributeError — no set_defaults handler exists
Argparse prints nothing and returns to the caller. The agent receives no command list, no usage. If the dispatch code then calls args.func(args) without a registered default, the program crashes with AttributeError. Either way, discovery fails — silently or noisily, depending on the dispatch code, which is harder to diagnose than an explicit error.
Anti-pattern 2 — explicit required=True: exits 2
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
# bare invocation → exit 2, "the following arguments are required: command"
# (lowercase — argparse uses the dest value in the error message)
A deliberate choice to force users to provide a subcommand. Reasonable for human callers; fatal for agents. The non-zero exit causes the agent to classify the tool as broken before discovering a single command.
Both break the entry-point contract. The fix addresses both.
The Fix for CLI Authors
argparse
Both changes are required. required=False alone silently does nothing.
import sys, argparse
parser = argparse.ArgumentParser(description="my-tool — brief description")
subparsers = parser.add_subparsers(dest="command", required=False)
# register subcommands here
def _root(args):
parser.print_help()
sys.exit(0)
parser.set_defaults(func=_root)
args = parser.parse_args()
args.func(args)
Click
Click's invoke_without_command=True provides this correctly:
@click.group(invoke_without_command=True)
@click.pass_context
def cli(ctx):
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# exit 0 is implicit
clap (Rust)
clap's arg_required_else_help = true on the top-level command produces exit 0 with help when no subcommand is given:
#[derive(Parser)]
#[command(arg_required_else_help = true)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
Cobra (Go)
Cobra calls the root command's Run or RunE when no subcommand is given. Set it to print help:
var rootCmd = &cobra.Command{
Use: "my-tool",
Short: "Brief description",
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}
Structuring the Help Output
Exit 0 with a subcommand list is the floor, not the ceiling. The help output is the first screen a human reads — and often the last they consult before deciding whether the tool fits their workflow. Structure it to answer three questions in order: what does this do, where do I start, and where do I go next.
Group commands by role, not alphabet
Alphabetical ordering exposes implementation detail. Role-based grouping encodes priority. A CORE COMMANDS group signals "start here"; ADDITIONAL COMMANDS signals "you'll find this later"; HELP TOPICS signals "concepts, not commands." The grouping lets a reader form a mental model before reading a single description.
CORE COMMANDS
issue: Manage issues
pr: Manage pull requests
repo: Manage repositories
ADDITIONAL COMMANDS
alias: Create command shortcuts
config: Manage configuration
HELP TOPICS
exit-codes: Exit codes used by this tool
formatting: Formatting options for JSON output
environment: Environment variables that can be used with this tool
Provide curated examples, not abstract syntax
Abstract usage syntax (tool <command> [flags]) describes the grammar. Examples demonstrate the idiom. Provide 2–4 examples that span distinct domains and represent the most common real workflows — not a complete inventory.
EXAMPLES
$ gh issue create
$ gh repo clone owner/repo
$ gh pr checkout 321
Three examples, three domains. A reader immediately grasps the scope of the tool without reading the full command list.
Make help topics first-class
Concepts like exit codes, output formats, and environment variables are often buried in man pages or online documentation. Listing them by name in the root help makes them discoverable in one invocation. An agent that sees exit-codes as a named topic knows to run tool help exit-codes before guessing. A human knows where to look before reaching for a browser.
Verification Checklist for CLI Authors
Three checks that must all pass before a CLI is considered agent-accessible:
| Check | Command | Expected result |
|---|---|---|
| Bare invocation | your-tool |
Exit 0, subcommand list on stdout |
| Clean environment | HOME=/tmp/empty your-tool |
Exit 0, no files created, no network calls |
| Non-TTY pipe | your-tool \| cat |
Help text still appears on stdout |
The clean environment check is not optional — REQ-F-068 requires that the empty-invocation path follows the same purity guarantees as --help. No config directory creation, no schema migration, no credential check.
For Agent Builders
Agents cannot control whether the CLIs they use follow this spec. The defensive first-contact probe handles both the clean case and the error case:
import subprocess, re
def probe_cli(tool: str) -> dict:
# Attempt 1 — bare invocation
result = subprocess.run(
[tool],
capture_output=True, text=True,
stdin=subprocess.DEVNULL, # never inherit agent stdin (§50)
timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
commands = re.findall(r'^\s{2,4}(\w[\w-]*)\s', result.stdout, re.MULTILINE)
return {"ok": True, "commands": commands, "source": "bare"}
# Attempt 2 — explicit --help flag
fallback = subprocess.run(
[tool, "--help"],
capture_output=True, text=True,
stdin=subprocess.DEVNULL,
timeout=10,
)
if fallback.returncode == 0:
commands = re.findall(r'^\s{2,4}(\w[\w-]*)\s', fallback.stdout, re.MULTILINE)
return {"ok": True, "commands": commands, "source": "help_flag"}
return {"ok": False, "commands": [], "source": "none"}
stdin=subprocess.DEVNULL must be set on every invocation — not just the probe. Without it, the subprocess inherits the agent's own stdin. Any tool that silently falls back to stdin (§50) will consume it, corrupting the agent's input stream.
Limitation: The --help fallback is not guaranteed. Some tools run credential or config checks before argument parsing and exit non-zero even on --help. If both probes fail, classify the tool as "unknown interface" and surface the failure to the caller rather than proceeding with guessed invocations.
For AI Agents
Use this decision table when encountering an unfamiliar CLI:
| Step | Command | Exit 0 + output? | Next step |
|---|---|---|---|
| 1 | <tool> |
Yes | Parse subcommand list; try --schema |
| 1 | <tool> |
No | Try step 2 |
| 2 | <tool> --help |
Yes | Parse subcommand list; try --schema |
| 2 | <tool> --help |
No | Tool is not usable; stop |
| 3 | <tool> --schema --output json |
Yes, valid JSON | Use as authoritative schema |
| 3 | <tool> --schema --output json |
No | Fall back to per-command --help |
Additional signals to watch for:
- Exit 0 with no output on bare invocation: go directly to
--help - A
shellorreplentry in the subcommand list: REPL risk (§37) — never invoke these subcommands without a TTY - Non-zero exit on
--help: tool runs initialization before argument parsing — treat as partially broken; try per-command<tool> <command> --helpdirectly
Related
| Document | Relationship |
|---|---|
| §21 Schema & Help Discoverability | Enforces: bare-invocation exit 0 now a prerequisite to §21 scoring |
| §1 Exit Codes & Status Signaling | Enforces: exit-code contract that bare invocation must satisfy |
| §52 Recursive Command Tree Discovery Cost | Composes: schema export assumes entry-point works |
| §37 REPL / Interactive Mode Accidental Triggering | Composes: shell subcommands visible in root help pose REPL risk |
| §50 Stdin Consumption Deadlock | Provides: reason stdin=DEVNULL is required in the probe pattern |
| REQ-F-068 | Enforces: empty-invocation purity — no side effects, exit 0 |
| REQ-F-009 | Provides: non-TTY detection that governs the empty-invocation path |
| research/argparse.md | Sources: required-subcommand anti-pattern documented in framework research |