Skip to content
Merged
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Python 3.10 or newer is required. Get an API key at [platform.hcompany.ai/settin
export HAI_API_KEY=hk-...
```

With the `cli` extra, `hai login` stores a key once instead, and `Client()` picks it up.

## Quickstart

Launch the built-in `h/web-surfer-pro` agent, which ships with its own browser, and describe the task in plain language. `run_session` polls until the agent finishes and returns the final answer.
Expand All @@ -63,7 +65,7 @@ print(result.status)
print(result.answer)
```

`Client()` reads `HAI_API_KEY` from the environment.
`Client()` reads `HAI_API_KEY` from the environment, else the key `hai login` stored in `~/.config/hai/.env`.

`result` is a `SessionRunResult`: `id`, `status`, `answer`, the accumulated `events`, and `final_changes`.

Expand Down Expand Up @@ -335,7 +337,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 takes a key you paste from [platform.hcompany.ai/settings/api-keys](https://platform.hcompany.ai/settings/api-keys), and stores it in `~/.config/hai/.env`. Any other command does the same on first use in a terminal, so `hai login` is optional. Scripts and `--json` runs never prompt: pipe a key into `hai login --key` or set `HAI_API_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 `~/.config/hai/.env`. Run `hai --help` for the full command set.

## Documentation

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "hai-agents"
version = "1.0.16"
version = "1.0.17"
description = "Python SDK for H Company's Agents API: autonomous agents powered by Holo."
requires-python = ">=3.10"
readme = "README.md"
Expand Down
46 changes: 45 additions & 1 deletion src/hai_agents/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

Fern emits the API surface as ``BaseClient``/``AsyncBaseClient``; these thin
subclasses add the object-oriented sugar (``run_session``, ``start_session``,
``session``) that delegates to the hand-written polling helpers.
``session``) that delegates to the hand-written polling helpers, and default
``api_key`` to the key ``hai login`` stored.
"""

from __future__ import annotations

import asyncio
import functools
import os
import typing
from pathlib import Path

import typing_extensions

from .base_client import AsyncBaseClient, BaseClient
from .core.api_error import ApiError
from .polling import (
AnswerT,
AsyncSessionHandle,
Expand All @@ -31,8 +36,46 @@
if typing.TYPE_CHECKING:
from hai_agents_local.runtime import Inference, LocalRuntime

API_KEY_VAR = "HAI_API_KEY"

_P = typing_extensions.ParamSpec("_P")


def credentials_path() -> Path:
"""The global `.env` that `hai login` writes: `$XDG_CONFIG_HOME/hai/.env`, else `~/.config/hai/.env`."""
return Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "hai" / ".env"


def _stored_api_key() -> typing.Optional[str]:
"""The `HAI_API_KEY` that `hai login` stored, if any."""
try:
lines = credentials_path().read_text(encoding="utf-8").splitlines()
except (OSError, RuntimeError, UnicodeDecodeError):
return None
for line in lines:
name, sep, value = line.strip().removeprefix("export ").partition("=")
if sep and name.strip() == API_KEY_VAR:
return value.strip().strip("'\"") or None
return None


def _default_api_key(init: typing.Callable[_P, None]) -> typing.Callable[_P, None]:
"""Resolve `api_key` as: argument, then `HAI_API_KEY`, then the key stored by `hai login`."""

@functools.wraps(init)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
if kwargs.get("api_key") is None:
api_key = os.getenv(API_KEY_VAR) or _stored_api_key()
if api_key is None:
raise ApiError(body=f"No API key found. Pass api_key, set {API_KEY_VAR}, or run `hai login`.")
kwargs["api_key"] = api_key
init(*args, **kwargs)

return wrapper


class Client(BaseClient):
__init__ = _default_api_key(BaseClient.__init__)
local_runtime: typing.Optional[LocalRuntime] = None
_owns_runtime = False
_auto_bridges = True
Expand Down Expand Up @@ -138,6 +181,7 @@ def sessions(self) -> SessionsClient:


class AsyncClient(AsyncBaseClient):
__init__ = _default_api_key(AsyncBaseClient.__init__)
local_runtime: typing.Optional[LocalRuntime] = None
_owns_runtime = False
_auto_bridges = True
Expand Down
4 changes: 2 additions & 2 deletions src/hai_agents/core/client_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ def get_headers(self) -> typing.Dict[str, str]:
import platform

headers: typing.Dict[str, str] = {
"User-Agent": "hai_agents/1.0.16",
"User-Agent": "hai_agents/1.0.17",
"X-HCompany-Client-Name": "hai_agents",
"X-HCompany-Client-Version": "1.0.16",
"X-HCompany-Client-Version": "1.0.17",
"X-HCompany-Client-Type": "sdk",
"X-HCompany-Language": "Python",
"X-HCompany-Runtime": f"python/{platform.python_version()}",
Expand Down
41 changes: 37 additions & 4 deletions src/hai_agents_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,21 +93,51 @@ def login(
"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 through the browser or with a pasted key, and store the key in ~/.config/hai/.env."""
state = _state(ctx)
if credentials.current_api_key() and not force:
console.print("Already signed in. Pass --force to rotate the key.")
return
if key:
_store_pasted_key(state.base_url)
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}"))
_sign_in(state.base_url)


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


def _sign_in_if_needed(state: AppState) -> None:
"""Commands sign in on first use in a terminal, so a fresh install needs no `hai login` step."""
if credentials.current_api_key(state.api_key) or state.json_output or not _interactive():
return
console.print("No API key found, so let's sign you in first.")
_sign_in(state.base_url)


def _sign_in(base_url: str | None) -> None:
console.print(
"How do you want to sign in?\n"
" 1. Google account, in your browser\n"
f" 2. Paste an API key from {credentials.API_KEYS_PAGE}"
)
choice = typer.prompt("Choice", default="1").strip()
if choice == "1":
_browser_sign_in(base_url)
elif choice == "2":
_store_pasted_key(base_url)
else:
_raise_cli_error(RuntimeError(f"Choice must be 1 or 2, got {choice!r}."))


def _browser_sign_in(base_url: str | None) -> None:
label = f"hai CLI ({socket.gethostname()})"
try:
minted = auth.login_and_mint(
credentials.portal_base(state.base_url),
credentials.portal_base(base_url),
label,
lambda url: console.print(f"Opening your browser. If it does not open, visit:\n {url}", style="dim"),
)
Expand Down Expand Up @@ -559,6 +589,7 @@ def mcp_install(
else:
_raise_cli_error(RuntimeError(f"Unknown client {client!r}. Run `hai mcp install list` to see supported ids."))

_sign_in_if_needed(state)
try:
resolved = credentials.resolve_api_key(state.api_key)
key = resolved() if callable(resolved) else resolved
Expand Down Expand Up @@ -700,6 +731,7 @@ def _run_bridge(state: AppState, bridge_type: type[LocalBridge], session_id: str
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
_sign_in_if_needed(state)
try:
bridge = bridge_type(
api_key=credentials.resolve_api_key(state.api_key),
Expand Down Expand Up @@ -784,6 +816,7 @@ def _state(ctx: typer.Context) -> AppState:


def _client(state: AppState) -> Client:
_sign_in_if_needed(state)
try:
return make_client(api_key=state.api_key, base_url=state.base_url)
except RuntimeError as exc:
Expand Down
29 changes: 10 additions & 19 deletions src/hai_agents_common/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
from dotenv import dotenv_values, set_key, unset_key

from hai_agents import AsyncClient, Client
from hai_agents.client import API_KEY_VAR, credentials_path
from hai_agents.environment import HaiAgentsEnvironment

ApiKey = str | Callable[[], str]

API_KEY_VAR = "HAI_API_KEY"
BASE_URL_VAR = "HAI_API_BASE_URL"
PORTAL_URL_VAR = "HAI_PORTAL_URL"

Expand All @@ -25,8 +25,7 @@
}
API_KEYS_PAGE = "https://platform.hcompany.ai/settings/api-keys"

LOCAL_ENV_PATH = Path(".env")
GLOBAL_ENV_PATH = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "hai" / ".env"
GLOBAL_ENV_PATH = credentials_path()


def portal_base(base_url: str | None = None) -> str:
Expand Down Expand Up @@ -105,9 +104,8 @@ def source(explicit: ApiKey | None = None) -> str | None:
return "argument"
if os.environ.get(API_KEY_VAR):
return "environment"
for path in _env_paths():
if path.exists() and dotenv_values(path).get(API_KEY_VAR):
return str(path)
if _stored(API_KEY_VAR):
return str(GLOBAL_ENV_PATH)
return None


Expand All @@ -119,18 +117,11 @@ def _client_kwargs(api_key: ApiKey | None, base_url: str | None) -> dict[str, Ap
return kwargs


def _env_paths() -> tuple[Path, ...]:
# CWD `.env` overrides the global config `.env`.
return (LOCAL_ENV_PATH, GLOBAL_ENV_PATH)
def _lookup(name: str) -> str | None:
return os.environ.get(name) or _stored(name)


def _lookup(name: str) -> str | None:
if os.environ.get(name):
return os.environ[name]
for path in _env_paths():
if not path.exists():
continue
value = dotenv_values(path).get(name)
if value:
return value
return None
def _stored(name: str) -> str | None:
if not GLOBAL_ENV_PATH.exists():
return None
return dotenv_values(GLOBAL_ENV_PATH).get(name) or None
1 change: 0 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ def test_help_renders_h_glyph() -> None:

def test_missing_api_key_is_clear(monkeypatch, tmp_path) -> None:
monkeypatch.delenv("HAI_API_KEY", raising=False)
monkeypatch.setattr(credentials, "LOCAL_ENV_PATH", tmp_path / "local.env")
monkeypatch.setattr(credentials, "GLOBAL_ENV_PATH", tmp_path / "global.env")

result = runner.invoke(app, ["sessions", "get", "sess_1"], env={})
Expand Down
57 changes: 51 additions & 6 deletions tests/test_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

import stat
import sys
from types import SimpleNamespace

import httpx
import pytest
from typer.testing import CliRunner

import hai_agents.client as sdk_client
import hai_agents_cli.app as app_module
from hai_agents import AsyncClient, Client
from hai_agents.core.api_error import ApiError
from hai_agents_cli import auth
from hai_agents_cli.app import app
Expand All @@ -21,8 +24,9 @@ def isolated_env(tmp_path, monkeypatch):
"""Point credential resolution at empty temp files and a clean environment."""
for var in (credentials.API_KEY_VAR, credentials.BASE_URL_VAR, credentials.PORTAL_URL_VAR):
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr(credentials, "LOCAL_ENV_PATH", tmp_path / "local.env")
monkeypatch.setattr(credentials, "GLOBAL_ENV_PATH", tmp_path / "global.env")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
monkeypatch.setattr(credentials, "GLOBAL_ENV_PATH", sdk_client.credentials_path())
credentials.GLOBAL_ENV_PATH.parent.mkdir(parents=True)


def test_env_var_beats_dotenv(monkeypatch):
Expand All @@ -33,12 +37,11 @@ def test_env_var_beats_dotenv(monkeypatch):
assert credentials.source() == "environment"


def test_local_dotenv_overrides_global():
def test_stored_key_is_found_and_reported():
credentials.GLOBAL_ENV_PATH.write_text("HAI_API_KEY=hk-global\n")
credentials.LOCAL_ENV_PATH.write_text("HAI_API_KEY=hk-local\n")

assert credentials.resolve_api_key() == "hk-local"
assert credentials.source() == str(credentials.LOCAL_ENV_PATH)
assert credentials.resolve_api_key() == "hk-global"
assert credentials.source() == str(credentials.GLOBAL_ENV_PATH)


def test_missing_key_raises_with_guidance():
Expand Down Expand Up @@ -146,6 +149,48 @@ def get_session_quota(self):
assert "rejected this key" in _error_text(result)


@pytest.mark.parametrize("client_type", [Client, AsyncClient])
def test_sdk_client_falls_back_to_the_key_hai_login_stored(monkeypatch, client_type):
with pytest.raises(ApiError, match="hai login"):
client_type()

credentials.save_api_key("hk-stored")
monkeypatch.delenv(credentials.API_KEY_VAR)
assert client_type()._client_wrapper._get_api_key() == "hk-stored"

monkeypatch.setenv(credentials.API_KEY_VAR, "hk-env")
assert client_type()._client_wrapper._get_api_key() == "hk-env"
assert client_type(api_key="hk-arg")._client_wrapper._get_api_key() == "hk-arg"


def test_commands_sign_in_on_first_use_then_run(monkeypatch):
listed = []
fake = SimpleNamespace(
sessions=SimpleNamespace(get_session_quota=lambda: None),
agents=SimpleNamespace(list_agents=lambda **_: listed.append(True) or SimpleNamespace(items=[])),
)
monkeypatch.setattr(app_module, "make_client", lambda **_: fake)
monkeypatch.setattr(app_module, "_interactive", lambda: True)

result = runner.invoke(app, ["agents", "list"], input="2\nhk-pasted\n")
monkeypatch.delenv(credentials.API_KEY_VAR, raising=False)

assert result.exit_code == 0, _error_text(result)
assert "hk-pasted" in credentials.GLOBAL_ENV_PATH.read_text()
assert listed == [True]


@pytest.mark.parametrize(("args", "interactive"), [(["agents", "list"], False), (["--json", "agents", "list"], True)])
def test_scripts_never_get_a_sign_in_prompt(monkeypatch, args, interactive):
monkeypatch.setattr(app_module, "_interactive", lambda: interactive)

result = runner.invoke(app, args, input="2\nhk-pasted\n")

assert result.exit_code == 1
assert "No API key found" in _error_text(result)
assert not credentials.GLOBAL_ENV_PATH.exists()


def _error_text(result) -> str:
return "\n".join(part for part in (result.output, result.stderr, str(result.exception)) if part)

Expand Down
1 change: 0 additions & 1 deletion tests/test_mcp_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ def test_install_requires_api_key(monkeypatch, tmp_path) -> None:
from hai_agents_common import credentials

monkeypatch.delenv("HAI_API_KEY", raising=False)
monkeypatch.setattr(credentials, "LOCAL_ENV_PATH", tmp_path / "local.env")
monkeypatch.setattr(credentials, "GLOBAL_ENV_PATH", tmp_path / "global.env")

result = runner.invoke(app, ["mcp", "install", "cursor"], env={})
Expand Down
Loading
Loading