Your CLI has a second user now

On this page
  1. What an agent does differently#
  2. Help text is the manual#
  3. Errors say what happened, why, and what to run next#
  4. Output is a contract#
  5. Arguments and flags are the API#
  6. Non-interactive when no one is there#
  7. Authentication: OIDC and OAuth for a tool that runs anywhere#
  8. The three flows#
  9. The command set#
  10. Storage, precedence, and scopes#
  11. A device-flow login in thirty lines#
  12. The checklist#
  13. The question I now ask#
For terminals & agents: curl -s https://jedarden.com/notes/cli-design-for-humans-and-agents.md

A worker spent forty minutes on a one-line change to an issue tracker. The command was right. The flag was wrong: --notes replaced the whole notes field instead of appending to it, and the help line never said so. The agent wrote its note, erased a colleague’s investigation, noticed the field looked thin, and “fixed” it by writing a longer note. Three times.

A person would have squinted at the first result and gone looking for an --append flag. The agent did what the help text told it was possible and nothing more, because the help text was the only manual it had.

Every CLI you ship now has that second user. It discovers the tool by running --help, parses stdout as data, treats the error message as its only hint about what to try next, and cannot type an answer when you ask it a question. Design for that user and the human gets a better tool too. The rules are mostly the ones clig.dev wrote down for humans years ago; the agent audience just removes the tolerance.

A person squints past a vague error. An agent loops on it.

What an agent does differently#

Six things, and each one turns a nice-to-have into a requirement:

  • It learns the tool from tool --help and tool <command> --help, not from a docs site it may never fetch.
  • It cannot answer a prompt. A confirmation question hangs the task until something times out.
  • It reads output literally, so a spinner, a pager, or an ANSI escape is noise it has to work around.
  • It retries on failure. An error that does not say what to change produces the same wrong call again.
  • It copies values from one command into the next, so anything printed, including a secret, ends up in a transcript.
  • It runs in CI, over SSH, and inside sandboxes with no browser and no keychain.

The payoff is symmetric. A CLI that satisfies those six points is one an agent can drive unsupervised, and it is also the one a new teammate can pick up without reading the wiki.

I’ll use a hypothetical issue tracker called trk for the examples. Every rule below is one I’ve either enforced in a tool of mine or paid for by not enforcing.

Help text is the manual#

An agent’s first command is tool --help and its second is tool <command> --help, and it will rarely look anywhere else. Help therefore has to be complete at every level, identical in shape across commands, and free to call.

  • -h and --help both work on every command and subcommand, print to stdout, and exit 0. Help on stderr with exit 1 reads as a failure to anything scripted. Don’t reuse -h for something else.
  • Help needs no login, no network, and no config file. If tool --help fails because the user is not authenticated, the agent cannot learn how to authenticate.
  • Examples come first, and every command has at least one. Agents copy examples; they fall back to the usage grammar only when no example fits. Show the common call, then one that uses the flags that change behavior.
  • Every flag line states its value type, its default, and the environment variable that can set it. --region <name> default us-east-1, env TRK_REGION is enough.
  • Say what a flag does to existing state when it is not obvious. An agent assumes --notes appends. If it replaces the whole field, the help line has to say so, or you get the forty-minute incident above.
  • List the exit codes and what each means. A documented code is one the agent can branch on; an undocumented non-zero is one it can only retry.
  • Missing required arguments print concise help (one-line description, one example, the required flags, a pointer to --help) and exit 2. Never hang waiting for stdin when stdin is a terminal and nothing is coming.
  • An unknown command or flag gets a nearest-match suggestion. Suggest, never auto-run.
  • Top-level help lists every command with one line each, grouped by task and ordered by frequency, not alphabetically. Mark deprecated commands rather than hiding them, so an agent that finds one in an old script learns the replacement.
  • Keep the layout identical across commands: Usage, Examples, Flags, Exit codes, See also. An agent that has parsed one help screen should be able to parse all of them.

A help screen that follows those rules:

$ trk update --help
Update an issue's status, assignee, or notes.

Usage:
  trk update <issue-id> [flags]

Examples:
  trk update TRK-412 --status in_progress
  trk update TRK-412 --append-note "Repro lives in tests/clone.rs"
  trk update TRK-412 --assignee alice --if-revision 7 --json

Flags:
  --status <open|in_progress|deferred>   closed is only reachable via `trk close`
  --append-note <text>                   adds a note; may be repeated
  --notes <text>                         REPLACES every existing note
  --assignee <name>                      env TRK_ASSIGNEE
  --clear-assignee
  --if-revision <n>                      fail if the issue's revision is not n
  --json                                 print the updated issue as JSON on stdout
  -h, --help

Exit codes:
  0 updated   2 usage error   3 issue not found   4 stale revision   5 invalid transition

See also: trk close, trk show, trk schema update

Two additions worth the effort for a tool agents will drive heavily: a schema command (or --help --json) that emits the commands, flags, types, and defaults as JSON, and a man page or help subcommand that renders the same text for people who prefer it. Both are generated from the same definitions as the help screen, so they cannot drift.

Errors say what happened, why, and what to run next#

An error message is the agent’s only feedback channel, so it carries three things: what failed, why, and the exact flag or command that fixes it. Anything less and the next attempt is the same call again.

$ trk update TRK-412 --status in_progress --if-revision 7
error[stale_revision]: TRK-412 is at revision 9; --if-revision 7 is stale
  cause: the issue changed after you last read it
  fix:   trk show TRK-412 --json   # read the current revision, then retry with --if-revision 9
$ echo $?
4

The same error under --json is one object on stderr, with a code that never changes wording even when the prose does:

{"error":{"code":"stale_revision","message":"TRK-412 is at revision 9; --if-revision 7 is stale","fix":"trk show TRK-412 --json, then retry with --if-revision 9","retryable":false,"exit":4}}
  • Errors go to stderr and stdout stays clean for data, so a pipeline that captures stdout never captures a half-written error.
  • Every error has a stable machine-readable code (stale_revision) alongside the human sentence. Exit codes are the coarse classification; error codes are the fine one. Agents and scripts branch on the code and show the sentence to the human.
  • Name the offending value and the flag it came from. “invalid value” is useless; “--status done is not a status; valid: open, in_progress, deferred (closed is reached via trk close)” is a fix.
  • A wrong-state error names the state and the command that leaves it: “TRK-412 is closed; --status cannot reopen it. Run trk reopen TRK-412.” Without that line the agent tries every status value in turn.
  • A wrong-tool or wrong-version error says so in those words. A tracker store written by version 2 and opened by version 1 should fail with “workspace schema 7 is newer than this binary (schema 5); upgrade trk”, not with a raw “no such column” from the database layer. I watched the generic version of that error lead an agent to apply version 1’s corruption-recovery recipe to a healthy version 2 store, which reinitialized it with the wrong schema. Check a schema marker before touching data, and make the mismatch message name the real cause.
  • A permission or policy refusal says whether the caller can fix it. “requires the admin role; ask an operator to grant it” tells an agent to stop and report. “access denied” tells it to try again with different flags.
  • Authentication errors name the login path, never the credential: “not logged in to trk.example.com; run trk auth login or set TRK_TOKEN”. Distinguish no credential, expired credential, and insufficient scope, and name the missing scope with the command that adds it.
  • Stack traces are off by default and on with --debug or TRK_DEBUG=1. Write the full trace to a log file and print its path. A traceback pasted into an agent’s context costs tokens and brings it no closer to the fix.
  • Partial failure is reported per item. If three of five updates failed, exit non-zero, list the three with their codes, and print the two that succeeded so the caller can resume without redoing them.
  • Repeated errors are grouped under one header. Fifty identical lines hide the one that differs.

One small, tool-wide exit-code table, documented in --help and used identically by every command:

CodeMeaningWhat an agent should do
0successcontinue
1unexpected failurereport it; do not retry blindly
2usage errorre-read --help, fix the call
3not foundcheck the identifier, do not retry as-is
4stale preconditionre-read state, then retry
5invalid state transitionrun the command the message names
6not authenticated or not authorizedrun trk auth login, or stop if the message says it needs an operator
7transient (network, rate limit)back off and retry
130interruptednothing

The split that matters most is retryable against not. A tool that returns 1 for everything forces every caller to guess, and an agent that guesses retries.

Output is a contract#

Treat stdout as a return value that someone will parse and stderr as the channel for everything a person might want to see. Every other output rule follows from that split.

$ trk create --title "Flaky clone test" --json
{"schema":"trk.issue/1","id":"TRK-413","revision":1,"status":"open","url":"https://trk.example.com/TRK-413"}

$ trk create --title "Flaky clone test"
Created TRK-413 (revision 1)
https://trk.example.com/TRK-413
  • Data goes to stdout; progress, warnings, hints, and success chatter go to stderr. trk list | jq must never see a “Fetching…” line.
  • Every command that produces output supports --json, including mutations, which print the resulting object. The agent’s next command is built from this output, so always include the identifier and revision it will need.
  • The JSON schema is stable and versioned. Fields are added, never renamed or repurposed; a removal is a major version. Stamp each object with its schema name ("schema":"trk.issue/1") or publish it through trk schema, so a consumer can detect drift instead of discovering it.
  • Lists are either one JSON object per line or one top-level array. Pick one per tool and document it. Paginated commands take --limit and --cursor and return the next cursor in the JSON, never an elided “and 37 more”.
  • Detect whether stdout is a terminal, and check stderr separately. When it is not, turn off color, spinners, progress bars, column truncation, and prompts.
  • Honor NO_COLOR when set and non-empty, TERM=dumb, and --no-color; offer --color=always for the reverse, because some agents run tools through a pseudo-terminal and want neither. no-color.org has the convention.
  • Never start a pager unless stdout is a terminal, and honor PAGER and --no-pager. A pager under an agent is a hang with no error message.
  • Human-readable output is also parseable in a pinch: one record per line, stable column order, and --plain to drop alignment and decoration for grep and awk.
  • --quiet suppresses success messages, never errors.
  • Timestamps in JSON are ISO 8601 in UTC. The human format may localize; the machine format never does.
  • Secrets never reach either stream, including under --debug. Print the retrieval path (the environment variable name, the secret store path) rather than the value, and redact anything that looks like a token in debug logs.

The human-first format is still the default, and that is correct: a person who types the command deserves readable output. The agent just needs --help to tell it that --json exists, and the JSON to be worth parsing.

Arguments and flags are the API#

Make every input expressible as a named flag, make precedence explicit, and make sure nothing that has to stay secret ever needs to be a flag value. A flag reads as documentation in a transcript; a bare positional reads as a guess.

Configuration precedence, highest first:
  1. flags            --server https://trk.example.com
  2. environment      TRK_SERVER
  3. config file      ~/.config/trk/config.toml
  4. built-in default
See the effective values:  trk config show --json
  • One positional at most: the noun the command acts on (trk update TRK-412). Everything else is a flag with a long name, and --flag value and --flag=value both work.
  • Precedence is flag, then environment, then config file, then default, documented once in --help and applied by every command. A config show --json command prints the effective values so an agent can see what it is about to run with.
  • A secret is never a flag value. Argument vectors are visible in ps, in shell history, and in every agent transcript. Accept a file (--token-file), stdin (--with-token reading stdin, or key=- for a field), or the system credential store. Environment variables leak too, through docker inspect and systemctl show; tolerate them for CI, but prefer a pipe or a file. What ends up in docs and tickets is then the retrieval path (“set TRK_TOKEN from gh auth token”), never the value.
  • - means stdin wherever a file or value is expected, and @path reads a value from a file. This is what lets openssl rand -base64 32 | trk secret set app/db password=- run without the value ever existing as a literal.
  • Replace and append are different flags with different names. --notes that overwrites and --append-note that adds cannot share a flag with a mode switch, because an agent that sees --notes will use it to add a note and erase the previous ones.
  • --dry-run exists on every mutating command and prints exactly what would change, in the same format the real run prints.
  • --yes skips confirmation prompts. For the most destructive commands, require --confirm <name> with the target’s name instead, so a script can still pass it but cannot pass it by accident.
  • Every mutation accepts --if-revision <n> (or an ETag) and fails with the stale-precondition code when the stored revision differs. This is the only thing that makes two agents on the same store safe. Without it the last writer wins and nobody is told.
  • Argument parsing has no side effects. Reject unknown flags and validate every positional before creating or writing anything. A real binary that took an output path as its first positional was handed --test-threads=2 by a test harness, created a directory literally named --test-threads=2/ with 37 generated files in it, and a blanket git add -A committed the lot.
  • Every command accepts the identifier the tool prints. If create prints TRK-413, then show, update, and close take TRK-413. Accepting a short form as well is fine; requiring a different form is not.
  • Flags are order-independent relative to positionals. trk update --json TRK-412 and trk update TRK-412 --json are the same call.
  • --version prints the tool version and its output-schema versions on one line, to stdout, exit 0, with no network call.
  • Environment variables share one prefix (TRK_) and each flag’s help line names its variable, so the mapping is never a guess.

A tool whose flags obey these rules is one where the agent’s transcript doubles as a reproducible script. That is the goal: every call it makes should be one a human could paste and understand.

Non-interactive when no one is there#

Decide once at startup whether a person is present, and if not, never ask a question: fail with the name of the flag that would have answered it. Interactive means stdin and stderr are terminals, CI and TRK_NO_PROMPT are unset, and --non-interactive was not passed.

$ trk --help
trk: issue tracking from the terminal

Usage: trk <command> [flags]

Commands:
  create, show, list, update, close, reopen   work with issues
  auth login|status|token|logout              authentication
  config show|set                             configuration
  schema, doctor                              machine-readable self-description

Agents and scripts:
  Pass --json for stable output. Set TRK_NO_PROMPT=1 to fail instead of prompt.
  Run `trk doctor --json` to check auth and connectivity. Exit codes: trk help exit-codes.
  • A missing input in non-interactive mode is a usage error that names the flag: “--assignee is required when not running interactively”. Never block reading stdin that will never arrive.
  • A destructive command without --yes fails with exit 2 and names --yes. It does not proceed and it does not prompt.
  • No browser launch, no clipboard, no pager, no spinner, no update-available banner. Print the URL, print the data, and stop. A banner on stderr is something an agent will try to act on.
  • Every network call has a default timeout and a --timeout flag. A hang produces no error message, so it is the worst outcome a tool can have.
  • Mutations are safe to repeat. create takes an --idempotency-key or is keyed by a natural name; close on an already-closed issue returns 0 with a note, or a distinct documented code. Agents retry on any failure, and a retry that creates a duplicate is a bug they cannot see.
  • Long-running operations return a job id immediately, with --wait to block and a status command to poll. Do not make the caller sit on a spinner it cannot read.
  • Ctrl-C and SIGTERM exit promptly with 130 or 143 and leave no partial state or stale lock behind. Agents kill processes on timeout, so the cleanup path runs more often than you think.
  • A lock error names the holder, its PID, and its age, and stale locks clear themselves. “database is locked” is a prompt to wait forever.
  • The tool describes itself. trk doctor --json reports the version, whether the caller is authenticated and as whom, which commands support --json, which environment variables are set, and whether the server is reachable. GitLab’s CLI has an open proposal for exactly this shape, including a non-terminal error that points at the JSON-producing alternative.
  • Top-level help has a short section for agents and scripts: which flag gives stable output, which variable disables prompts, where the exit codes are documented. Three lines are enough; the agent reads them on its first call.

None of this is a separate “agent mode”. It is the behavior any CI job or cron script already needed, applied by default whenever the tool cannot see a terminal.

Authentication: OIDC and OAuth for a tool that runs anywhere#

Choose the login flow from where the tool is running, not from one default: Authorization Code with PKCE and a loopback redirect when a browser is on the same machine, the Device Authorization Grant when there is a terminal but no browser, and a short-lived token from a workload identity when there is no person at all. A long-lived API key pasted into a flag is the option to design out.

Flat illustration in the site's avatar-logo style. An avatar seen from behind stands at a three-way fork in a path under the heading WHICH LOGIN? Each path leads to an open door: the left one under a laptop showing a browser window, labelled LOOPBACK; the middle one under a phone showing the code ABCD-1234, labelled DEVICE CODE; the right one under a server rack with no person near it, labelled WORKLOAD.

trk auth login
├─ no terminal attached ─────────────▶ workload identity, or an injected short-lived token
│                                       (CI OIDC token, Kubernetes service-account token, TRK_TOKEN)
└─ terminal attached
   ├─ a browser can open on this host ▶ Authorization Code + PKCE, loopback redirect on 127.0.0.1   (default)
   └─ no browser ────────────────────▶ Device Authorization Grant: URL and code on stderr, poll the token endpoint

Flags force a path: --web, --device, --with-token (reads the token from stdin)

A terminal without a browser gets the device grant; no terminal at all gets a platform identity or an injected token, never a device code it cannot hand to anyone.

The three flows#

FlowUse it whenWhat it needsMain riskReference
Authorization Code + PKCE, loopback redirecta person is at a machine with a browsera listener on 127.0.0.1, an ephemeral port, a browserlow: the code is useless without the verifier, which never leaves the processRFC 8252, RFC 7636; default in gcloud and in AWS CLI 2.22+
Device Authorization Granta terminal but no browser: SSH, a container, a dev VMa second device where the person can open a URLdevice-code phishing: the user code is a bearer for the polling sessionRFC 8628; gh auth login, aws sso login --use-device-code
Workload identity, token exchangeno person: CI, a cluster, an unattended agentan identity the runner already has (CI OIDC token, Kubernetes service-account token)a long-lived fallback token that leaksRFC 8693; cloud federation endpoints

PKCE with a loopback redirect is the default for interactive use. The CLI generates a code verifier, opens the browser to the authorize endpoint with the challenge and a random state, listens once on 127.0.0.1:<port>, validates state on the callback, exchanges the code with the verifier, and closes the listener. Bind to loopback only, accept one request, and time out. AWS made this the default for aws sso login in CLI 2.22 and moved device code behind --use-device-code (AWS Developer Tools Blog, 2024-11-18); WorkOS reaches the same recommendation for any enterprise CLI (WorkOS, 2026-05-08).

The device grant is the explicit fallback behind --device, and the automatic choice when no browser can be launched. The CLI requests a device code, prints the verification URL and user code to stderr, polls the token endpoint at the returned interval, adds five seconds on slow_down, keeps waiting on authorization_pending, and fails cleanly on expired_token or access_denied. Because the user code is a bearer credential for that session, keep its lifetime to minutes, and expect some identity providers to block the grant by policy: detect that error and say so instead of polling to the deadline.

Workload identity is the right answer for anything unattended. A CI runner or a pod already holds an identity token from its platform; exchange it for an access token at the identity provider’s federation endpoint so no long-lived secret exists anywhere. Where that is unavailable, accept a token through TRK_TOKEN or --with-token on stdin, keep it short-lived, and document the retrieval command rather than the value. An agent never completes a device flow on its own: it has no one to hand the code to. If it reaches one, it prints the URL and code, exits with the authentication code, and reports that a person has to finish the login.

The command set#

gh is the reference shape (gh auth login manual):

trk auth login [--web | --device | --with-token] [--hostname <host>] [--scopes <s,...>]
trk auth status [--json]        # host, account, token source, scopes, expiry; never the token
trk auth token                  # the one sanctioned way to get the value out, for piping
trk auth refresh --scopes <s>   # widen scopes without a full re-login
trk auth logout                 # revoke the refresh token at the provider, then clear storage
  • login picks the flow by environment and lets a flag force it. --with-token reads stdin so the value never touches argv.
  • status makes one cheap authenticated call and reports which credential source is active: environment, file, or credential store. “Which identity am I” is the first question when something returns 403, so answer it in --json too.
  • token prints the access token and nothing else to stdout, refreshing first if it has expired. It exists so that curl -H "Authorization: Bearer $(trk auth token)" works without the value being typed anywhere. Warn on stderr when stdout is a terminal.
  • logout revokes at the provider (RFC 7009), not just locally.

Storage, precedence, and scopes#

  • Store tokens in the system credential store (Keychain, Secret Service, Windows Credential Manager). Fall back to a mode-600 file under the tool’s config directory only with --insecure-storage or a visible warning, and never into the config file that people commit.
  • Store the refresh token, access token, expiry, scopes, and host together. Refresh silently before expiry; when a refresh fails, exit with the authentication code and the login command.
  • Precedence is TRK_TOKEN, then --token-file, then the stored credential, and auth status says which one won.
  • Request the smallest default scope set. An insufficient-scope error names the missing scope and prints trk auth refresh --scopes <name> as the fix.
  • Register the CLI as a public client with no client secret. A secret compiled into a distributed binary is not a secret.
  • Discover endpoints from <issuer>/.well-known/openid-configuration rather than hardcoding them. With OIDC you also get an ID token: validate iss, aud, exp, and nonce, and use its preferred_username or email claim to make auth status say who is logged in. Request offline_access where the provider requires it for a refresh token.
  • A command that needs authentication and has none exits 6 with “not logged in to trk.example.com; run trk auth login (interactive) or set TRK_TOKEN (scripts)”. It never launches a login flow on its own when no terminal is present.

A device-flow login in thirty lines#

import sys, time, requests

ISSUER, CLIENT_ID = "https://id.example.com", "trk-cli"   # public client: no secret
cfg = requests.get(f"{ISSUER}/.well-known/openid-configuration").json()

r = requests.post(cfg["device_authorization_endpoint"], data={
    "client_id": CLIENT_ID, "scope": "openid offline_access trk.issues"}).json()
print(f"Open {r['verification_uri']} and enter code {r['user_code']}", file=sys.stderr)
if "verification_uri_complete" in r:
    print(f"  or open {r['verification_uri_complete']}", file=sys.stderr)

interval, deadline = r.get("interval", 5), time.time() + r["expires_in"]
while time.time() < deadline:
    time.sleep(interval)
    t = requests.post(cfg["token_endpoint"], data={
        "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
        "device_code": r["device_code"], "client_id": CLIENT_ID}).json()
    if "access_token" in t:
        store(t)                      # credential store or a mode-600 file; never printed
        print("Logged in", file=sys.stderr)
        break
    if t["error"] == "slow_down":
        interval += 5
    elif t["error"] != "authorization_pending":
        die(6, f"login failed: {t['error']}")      # expired_token, access_denied, or a policy block
else:
    die(6, "login timed out; run `trk auth login --device` again")

Everything a person needs goes to stderr, the token goes to storage, and stdout stays empty so trk auth login --device && trk list --json composes.

The checklist#

Run this against any CLI before calling it ready for agents. Every line is something a reviewer can verify in under a minute with the binary in front of them.

Help

  • -h and --help work on every command, print to stdout, exit 0, and need no login or network
  • every command has at least one example; every flag line shows its type, default, and environment variable
  • exit codes are listed in help; an unknown command or flag gets a nearest-match suggestion
  • replace-versus-append and state-transition semantics are stated on the flag line itself

Errors

  • every error says what failed, why, and the exact fix, carries a stable code, and goes to stderr
  • exit codes distinguish usage, not found, stale precondition, auth, and transient failures
  • a schema or version mismatch is detected before any data is touched and named as such
  • stack traces appear only under --debug and are written to a file whose path is printed

Output

  • stdout carries data only; --json exists on every command, including mutations; the schema is versioned
  • a non-terminal stdout disables color, spinners, pagers, and truncation; NO_COLOR is honored
  • every mutation prints the identifier and revision the next command will need
  • no secret reaches either stream, including under --debug

Flags

  • precedence is flag, environment, config, default, documented once, visible via config show --json
  • secrets arrive by file, stdin, or credential store, never as an argument
  • mutations take --dry-run, --yes, and --if-revision; a parse failure has no side effects

Non-interactive

  • without a terminal there are no prompts, no browser, no pager; a missing input names its flag
  • every network call has a timeout; mutations are safe to repeat; SIGINT and SIGTERM exit cleanly
  • doctor --json exists and top-level help has a short paragraph for agents and scripts

Auth

  • PKCE with a loopback redirect by default, --device as the fallback, --with-token on stdin
  • auth status names the identity and the credential source, never the token
  • tokens live in the system credential store, refresh silently, and logout revokes at the provider
  • an unauthenticated non-interactive call exits with the auth code and the login command; it never starts a flow on its own

The question I now ask#

When I’m reviewing a CLI, mine or someone else’s, I no longer ask whether the help text is good. I ask:

If the only things an agent ever sees are --help, stdout, stderr, and the exit code, can it finish the job without guessing?

Flat illustration in the site's avatar-logo style. An avatar seen from behind, with a tag on its shirt reading AGENT, faces a bare wall holding exactly four framed panels: lines of text labelled --HELP, a pair of curly braces labelled STDOUT, a warning triangle labelled STDERR, and a large zero labelled EXIT. Caption: ALL IT EVER SEES.

If the answer is “it would need to know that --notes replaces,” or “it would need to open a browser,” or “it would need to read the stack trace,” then the tool has a human in the loop it hasn’t admitted to. Fix the help line, the error, the flag, or the login flow, and the human gets the same improvement for free.

— Jed


References: Command Line Interface Guidelines is the human-first baseline this extends. The auth section leans on RFC 8252 (native apps and loopback redirects), RFC 8628 (the device grant), RFC 8693 (token exchange), and RFC 9700 (the 2025 OAuth security best current practice). Related: ‘Ignore .env’ is not a defense on why a secret’s safety cannot rest on a sentence in a prompt, which is why the flag rules above refuse a secret in argv at all, and A mute gate is worse than a red one on why silence from a tool is the failure you don’t get told about.