---
paths:
  - "**/packages/cli/**"
  - "**/packages/*-cli/**"
  - "**/bin/*.js"
  - "**/bin/*.mjs"
  - "**/src/cli/**"
  - "**/src/commands/**"
---
# CLI — credentials, login doors, and the machine contract

What a `@nurix/*` command-line tool holds true about **how it authenticates, where it keeps what it is given, and how a script or an agent drives it**. Packaging, `npx` distribution, self-update, managed file blocks and publishing are the `nustack` skill's `cli-design.md` reference; the product API's own gate is the product's feature doc. A CLI that talks to no authenticated service reads only §5.

## 1. Two credentials, and the order they are read in

| Credential | Held by | Arrives by | Lives | Dies by | Verified by |
| --- | --- | --- | --- | --- | --- |
| **Grant** — an OAuth refresh token, rotated on every use | a person's machine | a login door (§2) | until revoked | revocation at the auth service | the auth service; the CLI never sees a secret it can check |
| **Token** — an opaque, prefixed secret (`nst_pat_…`) | a pipeline, an agent, a headless host | the `<TOOL>_TOKEN` environment variable | until revoked or expired | a column on its row | the product API, locally, against a stored hash |

- **Resolution order is fixed and never prompts: env token → OS store → file store → signed out.** A `<TOOL>_TOKEN` in the environment wins outright and touches no store; `login`, `logout` and `doctor` say so instead of acting on the store behind it.
- **The grant refreshes; the token never does.** A grant spends the refresh token for a fifteen-minute access JWT per resource and writes the rotated pair back. A token is presented as-is on every call, and a `401` against it is final — no refresh, no retry, no browser.
- **Only the refresh token and the cached access tokens are stored — never a password, never an ID token, never the token a pipeline injected.**
- **Never a `--token` flag.** `argv` is readable in `ps` and shell history on every host the command runs on; the environment is the only way a secret enters a process. A flag that exists for a legacy caller is documented as deprecated, never added new.
- **A token cannot mint a token.** Minting, listing and revoking are person-only routes; a credential that can create credentials turns one leak into an unbounded family.

## 2. Three login doors, one per situation

| Door | When | Shape |
| --- | --- | --- |
| **Loopback** | a browser runs on the same machine | authorization code + PKCE; `redirect_uri` is `http://127.0.0.1:<ephemeral>/callback`; the listener answers one matching `state` and closes |
| **Pasted code** | the browser cannot reach the terminal — SSH, a container | the same authorize URL with `redirect_uri` set to the auth service's own code page; the person pastes the code the page shows |
| **Device grant** (RFC 8628) | a GUI app with no listener of its own | `/device/code`, a `user_code`, polling `/oauth2/token` |

- **The CLI hardcodes one hostname — the product's.** The auth service's base URL, the client id and the resource audiences come from the product's discovery route (`GET /api/v1/auth/config`); a literal auth URL in a client pins every installed copy to one deployment.
- **Send `provider=<id>` on the authorize URL when the client already knows the provider** — the auth service's sign-in page starts that provider server-side with no paint when there is no session. Omitting it is the "click Google, then click Google again" defect.
- **Never open a browser, and never prompt, when stdin or stdout is not a TTY or when the env token is set.** The refusal is the CLI's own `SIGNED_OUT` code naming the login command, exit 1 — never a bare HTTP status, never a hang waiting on a page nobody can see.
- **Refusals are coded, and only two spend the credential.** `invalid_grant` is a dead grant: clear the item, run the login door, resume the command. `invalid_target` is a live grant asked for a resource it never covered: re-authorize wider, keep the item. A network failure, a `5xx` or a `429` keeps the item and raises — the item is shared with every client on the machine, and clearing it on a blip signs all of them out.

## 3. Where a credential lives

- **OS store where one is probed present, a `0600` file where none is, and the absence of an OS store is never a reason to refuse login.** macOS `security`, Linux `secret-tool` (libsecret); otherwise `~/.<tool>/credentials.json`, mode `0600`, in a `0700` directory, written atomically (temp file + rename). `<TOOL>_CREDENTIAL_STORE=auto|keychain|file` overrides the probe.
- **Never `localStorage`, never a world-readable file, never the project directory.** The link file a project carries (`.<tool>/project.json`) holds identifiers only — a project id, a host, a stack — and is committed; a secret beside it ships with the repo.
- **One item, several clients — read-modify-write with the schema versioned.** A CLI and a desktop app sharing an item merge into it, preserve keys they do not know, and stamp `schema_version`; a newer schema is refused whole and an older one reads as *sign in again*. Concurrent refreshes race by design — the auth service's reuse window absorbs the honest double refresh, so the second loser is a rotation, never a wipe.
- **Logout is revoke-then-clear, best effort on the revoke.** An offline machine still clears its local material.

## 4. The server's doors

- **Every machine credential carries a prefix the gate routes by, and the prefixed branches are ordered after the JWT branch.** Two credentials both presented as `Authorization: Bearer <opaque>` with no prefix cannot be told apart; a prefixed branch ahead of the JWT branch would have to decline every JWT correctly on every path, while one placed last cannot shadow a credential that already resolved.
- **One credential, one door.** A token minted for one surface is refused at every other, whatever it is worth — the audience rule is what bounds a leak.
- **A token row is `id`, owner, `label`, `token_hash`, `token_prefix`, `expires_at`, `revoked_at`, `last_used_at`.** The raw value appears in the `201` that minted it and nowhere else; the prefix is what a list shows; revocation sets a column and keeps the row, so the audit trail stays readable and the hash can never be re-issued; an expired, a revoked and an unknown token all answer the same `401`, so a caller never learns which.

## 5. The machine contract

A script or an agent drives the CLI as often as a person does, and it reads three channels: **stdout carries the one datum** — the URL, the id, or the `--json` envelope — **stderr carries everything else, and the exit code is the verdict.** `cmd > out.txt` with a `0` exit is the whole contract for the caller; a progress line on stdout breaks it. TTY detection, `--yes`, and failing loudly outside a TTY are `cli-design.md` §5; on top of it:

- **`--json` implies non-interactive**, and every error is a coded envelope, never prose.
- **A command that waits on a remote job offers `--no-wait`** (return the id at once) **and, where the job streams a log, `--ci`** (stream the build log, exit with the job's verdict, fall back to polling when the stream dies). The default waits and streams.
- **Upload and go-live are separate verbs when the platform has a pointer.** `deploy --no-promote` stages, `promote` and `rollback` move the pointer, and a `deploy` without the flag does both. A rollback that rebuilds is a redeploy, not a rollback.
- **Pack what git would ship.** `.gitignore` plus a `.<tool>ignore` decide what leaves the machine; `--no-gitignore` is the escape hatch, named, never the default.

## 6. Pitfalls — symptom → cause → fix

- **The sign-in page painted although the client sent `provider=`** → the hint fell off a hop, or the URL carries `error=` from an earlier attempt (the page always paints on `error=`) → follow the chain hop by hop with `curl -s -o /dev/null -D - <url>` and read each `location:`; the hint must survive every hop.
- **Works on macOS, asks to log in on every run elsewhere** → OS-store-only storage with no file fallback → §3.
- **CI hangs at the first authenticated command** → a prompt or a browser opened outside a TTY → §2; the fix is the `SIGNED_OUT` refusal, not a longer timeout.
- **Two commands at once, and the machine is "signed out"** → a concurrent refresh treated `invalid_grant` on the loser as a dead grant → check the service's reuse window before clearing anything (§3).
- **A `401` on a `<TOOL>_TOKEN` call loops or opens a browser** → the token took the grant's refresh path → §1: a token's `401` is final.
