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()
},
}
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 |