Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@

### Added

- **`brew-cli login` signs in through the browser.** On an interactive
terminal it starts a sign-in on brew.new, prints the link and a code
("Check the code in your browser matches: WDJB-MJHT"), opens the page,
and polls until the person approves; then it stores a new brand key
(`CLI · <computer>`) in the config at mode 0600 and prints
`Signed in as <email> to <brand>`. The key is encrypted to a one-time
P-256 key pair the CLI makes for the sign-in and travels only through
the poll, so it also works from Codespaces, Replit, Gitpod, SSH and CI,
where the CLI prints the link instead of opening a browser. New flags:
`--browser`, `--paste` (the old prompt), `--no-open`, `--wait
<duration>`, `--no-wait` (exit 3 at once with `{ status: "pending",
verificationUrl, userCode, expiresAt, resumeCommand }`, keeping the
pending sign-in in `pending-auth.json`, mode 0600) and `--resume`.
Denied, expired and unknown sign-ins exit 3 with `CLI_LOGIN_DENIED`,
`CLI_LOGIN_EXPIRED` or `CLI_LOGIN_INVALID_GRANT`; a server without
browser sign-in exits 3 with `CLI_LOGIN_UNAVAILABLE`. `--api-key`, a
piped key and `BREW_API_KEY` work as before, and `login` off a terminal
with no key still exits 2. JSON output gains `method` (`browser`,
`api-key`, `stdin`, `paste`) and, after a browser sign-in, `brandId`,
`brandName`, `userEmail` and `keyId`.
- `whoami` shows `userEmail` and `brandName` for a key stored by a browser
sign-in (`null` otherwise); `config get|list` read them, and `logout`
clears them and any pending sign-in.
- The brand id a browser sign-in stores belongs to that key: a key from
`--api-key` or `BREW_API_KEY` never sends it, and `logout` or a pasted
key drops it. A sign-in against a non-default `--api-url` stores that
URL with the key. The `Signed in as …` line shows even under `--quiet`.

- **`brew-cli mcp install`** adds the Brew MCP server to Cursor
(`.cursor/mcp.json`), VS Code (`.vscode/mcp.json`), Claude Code
(`.mcp.json`), Gemini CLI (`.gemini/settings.json`), and, when named,
Expand Down
55 changes: 50 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ are attached to every [GitHub Release](https://github.com/GetBrew/brew-cli/relea
## Quick start

```bash
brew-cli login # paste an API key from https://brew.new/settings/api
brew-cli login # sign in through the browser (or --paste a key)
brew-cli doctor # trust check: auth, reachability, CLI-vs-API drift (exit-code gated)
brew-cli whoami
brew-cli contacts search --filter email:equals:jane@example.com
Expand Down Expand Up @@ -89,6 +89,49 @@ VS Code results carry a one-click install `deeplink`.

## Authentication

### Sign in through the browser

```bash
brew-cli login # opens brew.new, then waits for approval
brew-cli login --no-open # print the link instead of opening a browser
```

On an interactive terminal, `login` starts a sign-in on brew.new, prints
the link and a code, and opens the page in your browser. Check that the
code on the page matches the one in your terminal, pick a brand, and
approve. The CLI then prints `Signed in as <you> to <brand>` and stores a
new brand key, named `CLI · <computer>`, in `~/.config/brew-cli/config.json`
(mode 0600). It is an ordinary brand key: it is listed, and can be revoked,
under Settings, API keys.

The key never passes through the browser. The CLI makes a one-time key
pair, Brew encrypts the new key to it on approval, and the CLI collects it
by polling, so sign-in also works from GitHub Codespaces, Replit, Gitpod,
an SSH session or CI: there the CLI does not try to open a browser, and you
open the link on any device.

For agents and scripts, which cannot wait at a prompt:

```bash
brew-cli login --no-wait --json # exit 3: {"status":"pending","verificationUrl":…,"userCode":…,"expiresAt":…,"resumeCommand":…}
brew-cli login --resume # after the person approved: collects and stores the key
brew-cli login --resume --wait 2m # wait at most two minutes, else exit 3 pending again
```

`--no-wait` keeps the pending sign-in (its poll secret and private key) in
`~/.config/brew-cli/pending-auth.json` (mode 0600) until it is approved,
denied or expired. A denied or expired sign-in exits 3 with
`CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED`; run `login` again. A sign-in
lasts ten minutes.

### API keys

```bash
brew-cli login --paste # paste a key at the prompt
brew-cli login --api-key brew_xxxxxxxx # or pass it
echo "$BREW_API_KEY" | brew-cli login # or pipe it
```

| Precedence | Source |
| --- | --- |
| 1 | `--api-key <key>` flag |
Expand Down Expand Up @@ -133,10 +176,12 @@ Designed for AI agents as first-class users:
trigger fires) never hang waiting for input: non-interactive callers
get exit `4` plus a JSON envelope containing a ready-to-run
`confirmCommand`; pass `--yes` to proceed. Humans on a TTY get a y/N
prompt. Test sends (`emails send --test`) skip the gate. Printed
commands (`confirmCommand`, `retryCommand`, "run … login") start the
way the CLI was invoked: a wrapper that runs it through a package
runner sets `BREW_CLI_INVOKED_AS` (for example
prompt. Test sends (`emails send --test`) skip the gate. Sign-in works
the same way: `login --no-wait --json` exits `3` with the link and code
to show a person, and a `resumeCommand` to run once they approved.
Printed commands (`confirmCommand`, `retryCommand`, `resumeCommand`,
"run … login") start the way the CLI was invoked: a wrapper that runs it
through a package runner sets `BREW_CLI_INVOKED_AS` (for example
`npx -y @brew.new/cli@<version>`), so they re-run through it.
- **Saved email recovery** — `409 EMAIL_RESULT_UNAVAILABLE` means the
generation or edit committed. Do not repeat the write. Read the exact
Expand Down
19 changes: 14 additions & 5 deletions docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ a `confirmCommand` otherwise, `--yes` to proceed).

| Command | Class | API route | Summary |
| --- | --- | --- | --- |
| `brew-cli login` | write | — | Store an API key for this machine (validated against the API) |
| `brew-cli login` | write | — | Sign in through the browser, or store an API key, for this machine |
| `brew-cli logout` | write | — | Remove the stored API key from this machine |
| `brew-cli whoami` | read | — | Show the resolved credential, brand, and API target |
| `brew-cli whoami` | read | — | Show the resolved credential, brand, API target, and who approved a browser sign-in |
| `brew-cli config list` | read | — | Show the stored configuration (API key masked) |
| `brew-cli config get` | read | — | Read one stored configuration value |
| `brew-cli config set` | write | — | Store a configuration value (brandId or apiUrl) |
Expand Down Expand Up @@ -177,12 +177,21 @@ a `confirmCommand` otherwise, `--yes` to proceed).

### brew-cli login

Store an API key for this machine (validated against the API)
Sign in through the browser, or store an API key, for this machine

- Class: write
- `--browser` — Sign in through the browser (the default on an interactive terminal)
- `--paste` — Paste an API key instead (or pipe one on stdin)
- `--no-open` — Print the sign-in link and code without opening a browser
- `--wait <duration>` — Wait at most this long for approval (90s, 5m), then exit 3 with a pending result to --resume
- `--no-wait` — Print the sign-in link and code, and exit 3 at once with a pending result (agents: with --json, then --resume)
- `--resume` — Collect the pending browser sign-in started with --no-wait

```bash
brew-cli login
brew-cli login --no-wait --json
brew-cli login --resume
brew-cli login --no-open
brew-cli login --api-key brew_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
echo "$BREW_API_KEY" | brew-cli login
```
Expand All @@ -199,7 +208,7 @@ brew-cli logout

### brew-cli whoami

Show the resolved credential, brand, and API target
Show the resolved credential, brand, API target, and who approved a browser sign-in

- Class: read

Expand All @@ -223,7 +232,7 @@ brew-cli config list
Read one stored configuration value

- Class: read
- Argument `key` — brandId | apiUrl | apiKey
- Argument `key` — brandId | apiUrl | apiKey | userEmail | brandName

```bash
brew-cli config get brandId
Expand Down
15 changes: 15 additions & 0 deletions skills/brew-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ this build lacks (update the CLI); auth/reachability failures name the fix.
## Auth and targeting

- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).
- No key and a person to ask? Sign in through their browser without
blocking on it:

```bash
brew-cli login --no-wait --json # exit 3: {status: "pending", verificationUrl, userCode, expiresAt, resumeCommand}
```

Show the person `verificationUrl` and `userCode`, and ask them to approve
on brew.new only if the code on the page matches. Then run the
`resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and
reports `userEmail` and `brandName`; exit 3 with `status: "pending"`
means not approved yet (add `--wait 2m` to wait for it); exit 3 with
`CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for
or print the key itself. A plain `brew-cli login` off a terminal exits 2
instead of waiting.
- Default target is production. For a local dev server:
`BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in
`.env.test.local` as `TEST_API_KEY`).
Expand Down
19 changes: 12 additions & 7 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { maskApiKey } from '../lib/client'
import {
type ConfigKey,
configPath,
isConfigKey,
readConfig,
Expand Down Expand Up @@ -27,7 +28,11 @@ export const configGetCommand = defineCommand({
sdkMethod: null,
commandClass: 'read',
args: [
{ name: 'key', summary: 'brandId | apiUrl | apiKey', isRequired: true },
{
name: 'key',
summary: 'brandId | apiUrl | apiKey | userEmail | brandName',
isRequired: true,
},
],
examples: ['brew-cli config get brandId'],
run: async ({ ctx, args }) => {
Expand Down Expand Up @@ -83,23 +88,23 @@ function maskedConfig(ctx: CliContext): Record<string, string | undefined> {
}
}

function requireKnownKey(
value: string | undefined
): 'apiKey' | 'apiUrl' | 'brandId' {
function requireKnownKey(value: string | undefined): ConfigKey {
if (value !== undefined && isConfigKey(value)) {
return value
}
throw new CliUsageError('Unknown key. Expected brandId, apiUrl, or apiKey.')
throw new CliUsageError(
'Unknown key. Expected brandId, apiUrl, apiKey, userEmail, or brandName.'
)
}

function requireSettableKey(
value: string | undefined,
ctx: CliContext
): 'apiUrl' | 'brandId' {
if (value === 'apiKey') {
if (value === 'apiKey' || value === 'userEmail' || value === 'brandName') {
const prefix = getInvocationPrefix(ctx.io.env)
throw new CliUsageError(
`The API key is managed by \`${prefix} login\` / \`${prefix} logout\`.`
`${value === 'apiKey' ? 'The API key is' : `${value} is`} managed by \`${prefix} login\` / \`${prefix} logout\`.`
)
}
const key = requireKnownKey(value)
Expand Down
Loading