Skip to content
Draft
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ hai sessions watch <session-id>
hai mcp install
```

`hai login` signs in through the browser with Google and stores a key in `~/.config/hai/.env`. Without a Google account or a browser, create a key at [platform.hcompany.ai/settings/api-keys](https://platform.hcompany.ai/settings/api-keys) and run `hai login --key`. `hai mcp install` adds the hai-agents MCP server to Cursor, VS Code, Claude Code, and other MCP clients. Credentials resolve from `--api-key`, then `HAI_API_KEY`, then a local `.env`, then `~/.config/hai/.env`. Run `hai --help` for the full command set.
`hai login` signs in with Google in the browser or with email and password (`hai login --email you@example.com` skips the question) and stores a key in `~/.config/hai/.env`. To use a key you already created at [platform.hcompany.ai/settings/api-keys](https://platform.hcompany.ai/settings/api-keys), run `hai login --key`. `hai mcp install` adds the hai-agents MCP server to Cursor, VS Code, Claude Code, and other MCP clients. Credentials resolve from `--api-key`, then `HAI_API_KEY`, then a local `.env`, then `~/.config/hai/.env`. Run `hai --help` for the full command set.

## Documentation

Expand Down
52 changes: 41 additions & 11 deletions src/hai_agents_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,35 +86,65 @@ def configure(
def login(
ctx: typer.Context,
force: bool = typer.Option(False, "--force", help="Re-authenticate and rotate the stored key."),
email: str | None = typer.Option(
None,
"--email",
metavar="EMAIL",
help="Sign in with this email and a password (asked on a hidden prompt) instead of the browser.",
),
key: bool = typer.Option(
False,
"--key",
help=f"Store an existing API key (from {credentials.API_KEYS_PAGE}) instead of signing in through the browser. "
help=f"Store an existing API key (from {credentials.API_KEYS_PAGE}) instead of signing in. "
"Reads it from a hidden prompt, or from stdin when piped.",
),
) -> None:
"""Sign in through the browser and store an API key in ~/.config/hai/.env."""
"""Sign in with Google in the browser or with email and password, and store an API key in ~/.config/hai/.env."""
state = _state(ctx)
if key:
_store_pasted_key(state.base_url)
return
if credentials.current_api_key() and not force:
console.print("Already signed in. Pass --force to rotate the key.")
return
if not sys.stdin.isatty():
_raise_cli_error(RuntimeError(f"login needs an interactive terminal and a browser. {auth.KEY_FALLBACK}"))
if not _interactive():
_raise_cli_error(RuntimeError(f"login needs an interactive terminal. {auth.KEY_FALLBACK}"))

portal = credentials.portal_base(state.base_url)
label = f"hai CLI ({socket.gethostname()})"
try:
minted = auth.login_and_mint(
credentials.portal_base(state.base_url),
label,
lambda url: console.print(f"Opening your browser. If it does not open, visit:\n {url}", style="dim"),
)
if email is None:
email = _ask_sign_in_method()
if email is None:
minted = auth.login_and_mint(
portal,
label,
lambda url: console.print(f"Opening your browser. If it does not open, visit:\n {url}", style="dim"),
)
else:
password = typer.prompt("Password", hide_input=True)
minted = auth.login_with_password(
portal, label, email, password, ask_code=lambda: typer.prompt("Authentication code")
)
except Exception as exc:
_raise_cli_error(exc)
path = credentials.save_api_key(minted)
console.print(f"Signed in. Wrote {credentials.API_KEY_VAR} to {path}.")
path = credentials.save_api_key(minted.key)
# Names only, never ids: a key minted into the wrong account must be visible at a glance.
where = f" in organization {escape(minted.organization)}" if minted.organization else ""
console.print(f"Signed in as {escape(minted.email)}{where}. Wrote {credentials.API_KEY_VAR} to {path}.")


def _interactive() -> bool:
return sys.stdin.isatty()


def _ask_sign_in_method() -> str | None:
"""The email to sign in with, or None to sign in with Google in the browser."""
console.print("How do you sign in to H?\n 1. Google account, in your browser\n 2. Email and password")
choice = typer.prompt("Sign-in method", default="1").strip()
if choice == "2":
return typer.prompt("Email").strip()
return None


def _store_pasted_key(base_url: str | None) -> None:
Expand Down
94 changes: 80 additions & 14 deletions src/hai_agents_cli/auth.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Browser sign-in: RFC 8252 loopback redirect + PKCE, then mint an API key."""
"""Sign in to the portal (browser + PKCE, or email + password with optional TOTP), then mint an API key."""

from __future__ import annotations

import base64
import contextlib
import dataclasses
import hashlib
import http.server
import secrets
Expand All @@ -20,15 +22,24 @@

SIGN_IN_TIMEOUT_S = 180
KEY_FALLBACK = (
f"Browser sign-in works with Google accounts. Otherwise create a key at {API_KEYS_PAGE} and run `hai login --key`."
"Browser sign-in needs a Google account; email and password accounts sign in with `hai login --email you@example.com`. "
f"Or create a key at {API_KEYS_PAGE} and run `hai login --key`."
)
SDK_AUTH_HEADERS = {"X-SDK-Auth": "true"} # tokens in the JSON body instead of cookies


class PortalError(RuntimeError):
"""A portal request failed; the message is the portal's own explanation."""


def login_and_mint(portal: str, label: str, on_open: typing.Callable[[str], None]) -> str:
@dataclasses.dataclass(frozen=True)
class SignedIn:
key: str
email: str
organization: typing.Optional[str] # name only, never an id


def login_and_mint(portal: str, label: str, on_open: typing.Callable[[str], None]) -> SignedIn:
"""Run the full browser sign-in and return a freshly minted API key."""
verifier, challenge = _pkce_pair()
redirect_uri = _free_redirect_uri()
Expand All @@ -51,17 +62,72 @@ def login_and_mint(portal: str, label: str, on_open: typing.Callable[[str], None
)
except PortalError as exc:
raise PortalError(f"sign-in failed: {exc} {KEY_FALLBACK}") from None
client.headers["Authorization"] = f"Bearer {token.json()['access_token']}"

me = _ok(client.get(f"{portal}/api/auth/me")).json()
org_id = me.get("org_id") or (me.get("organization") or {}).get("id")
if not org_id:
owned = _ok(client.get(f"{portal}/api/organizations/owned")).json()
if not owned:
raise RuntimeError("no organization is available to mint a key against.")
org_id = owned[0]["id"]

return _mint_key(client, portal, org_id, label)["key"]
body = token.json()
client.headers["Authorization"] = f"Bearer {body['access_token']}"
return _mint_for_signed_in_user(client, portal, label, session_id=body.get("session_id"))


def login_with_password(
portal: str,
label: str,
email: str,
password: str,
ask_code: typing.Callable[[], str],
transport: typing.Optional[httpx.BaseTransport] = None,
) -> SignedIn:
"""Email + password login (TOTP code when asked), then mint a key."""
with httpx.Client(timeout=20.0, transport=transport) as client:
credentials = {"email": email.strip(), "password": password}
body = _ok(client.post(f"{portal}/api/auth/token", json=credentials, headers=SDK_AUTH_HEADERS)).json()
if body.get("mfa_required"):
body = _ok(
client.post(
f"{portal}/api/auth/token-mfa",
json={**credentials, "code": ask_code().strip()},
headers=SDK_AUTH_HEADERS,
)
).json()
client.headers["Authorization"] = f"Bearer {_body_field(body, 'access_token')}"
return _mint_for_signed_in_user(client, portal, label, session_id=_body_field(body, "session_id", None))


def _body_field(body: typing.Mapping[str, typing.Any], name: str, default: typing.Any = ...) -> typing.Any:
"""Body keys are cookie-prefixed per environment: ``access_token``, ``staging_access_token``, ..."""
for key, value in body.items():
if key.endswith(name) and isinstance(value, str) and value:
return value
if default is not ...:
return default
raise PortalError(f"the portal did not return {name} for this login.")


def _mint_for_signed_in_user(
client: httpx.Client, portal: str, label: str, session_id: typing.Optional[str] = None
) -> SignedIn:
"""Mint a key for the signed-in user, then revoke the web session: the key is the credential."""
me = _ok(client.get(f"{portal}/api/auth/me")).json()
email = me.get("email") or (me.get("user") or {}).get("email") or "unknown"
org_id = me.get("org_id") or (me.get("organization") or {}).get("id")
if not org_id:
owned = _ok(client.get(f"{portal}/api/organizations/owned")).json()
if not owned:
raise RuntimeError("no organization is available to mint a key against.")
org_id = owned[0]["id"]
key = _mint_key(client, portal, org_id, label)["key"]
organization = _organization_name(client, portal, org_id)
if session_id:
with contextlib.suppress(httpx.HTTPError):
client.delete(f"{portal}/api/auth/sessions/{session_id}")
return SignedIn(key=key, email=str(email), organization=organization)


def _organization_name(client: httpx.Client, portal: str, org_id: str) -> typing.Optional[str]:
with contextlib.suppress(httpx.HTTPError, ValueError, TypeError):
orgs = client.get(f"{portal}/api/organizations/").json()
for org in orgs if isinstance(orgs, list) else []:
if isinstance(org, dict) and str(org.get("id")) == str(org_id) and org.get("name"):
return str(org["name"])
return None


def _ok(response: httpx.Response) -> httpx.Response:
Expand Down
Loading
Loading