fix(cli): pip install hanzo shipped a 10-command CLI, not the 56-command one

`hanzo` depends on `hanzo-cli`, and BOTH declared `[project.scripts] hanzo`.
Entry-point resolution is install-order dependent, so which CLI a user got was
a coin flip — and hanzo-cli kept winning. The result:

  $ hanzo auth login
  Error: No such command 'auth'.

So the "auth login is broken by Cloudflare 1010" story was real but unreachable:
users never got as far as the HTTP call, because the binary they had did not
have an `auth` command at all. Publishing that fix would have changed nothing.

One command, one owner:
- hanzo-cli drops its console script. `hanzo` is the ONE command.
- `paas` and `bot` are mounted into it — the only two groups with no equivalent.
  `kms` is NOT mounted: `hanzo secrets` is a strict superset (audit/grant/
  revoke/rollback/rotate/versions on top of the same get/list/set/delete), and
  two commands for one concern is what we are removing.
- hanzo_cli grows a __main__ so it stays invocable as `python -m hanzo_cli`,
  and its e2e suite targets that instead of whatever `hanzo` resolves to.

Endpoints, one definition:
- models.IAMConfig (the class the package EXPORTS) gains the HIP-0111 endpoint
  properties. config.IAMConfig had them, but nothing imports that one — a
  second same-named class nobody uses is how the legacy path survived.
- config.py + fastapi.py stop hand-assembling paths and read the constants.
- password_login posted to `/oauth/token`, which is not a 404: IAM serves a
  200 text/html SPA catch-all for unregistered paths, so it received a login
  PAGE and json() blew up on HTML. Verified: legacy 200 text/html vs canonical
  401 application/json.

PKCE, because this is a public client:
- browser_login ships no client_secret, so the authorization code was the only
  secret in the flow and it arrives over a plaintext loopback redirect. The SDK
  already accepted code_challenge/code_verifier; the CLI simply never passed
  them. RFC 8252 §8.1 / RFC 7636 S256.

Version, one source:
- pyproject said 0.4.4, __init__ said 0.3.47, cli.py said 0.3.48, and the
  installed binary reported 0.1.0 — four answers, none right. Now resolved
  from the installed distribution.

Verified on a clean venv: entry-point providers 2 -> 1, commands 10 -> 58,
`hanzo --version` 0.1.0 -> 0.4.4, all four endpoints canonical. hanzo-iam
19/19 pass; hanzo-cli's 20 failures are unchanged before and after and are all
live-service auth (no stored token in this environment).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
Zach Kelling
2026-07-26 14:51:42 -07:00
co-authored by hanzo-dev
parent 4ad6b61e2c
commit 205f1c0347
9 changed files with 123 additions and 16 deletions
+11
View File
@@ -0,0 +1,11 @@
"""Entry point for `python -m hanzo_cli`.
This package no longer installs a `hanzo` console script — that command name
belongs to the `hanzo` package alone. This module is how hanzo_cli's own
command tree is invoked directly, which is what its test suite targets.
"""
from hanzo_cli.cli import main
if __name__ == "__main__":
main()
+32 -3
View File
@@ -8,6 +8,8 @@ Credential chain:
from __future__ import annotations
import base64
import hashlib
import http.server
import json
import os
@@ -231,10 +233,24 @@ def browser_login(port: int = CALLBACK_PORT) -> dict[str, Any]:
redirect_uri = f"http://localhost:{port}{CALLBACK_PATH}"
state = secrets.token_urlsafe(32)
# PKCE is not optional here. This is a native app with a public client
# (client_secret=""), so the authorization code is the ONLY secret in the
# flow and it arrives over a plaintext loopback redirect that any other
# local process can race. RFC 8252 §8.1 requires PKCE for exactly this
# shape; without a verifier an intercepted code is directly redeemable.
code_verifier = secrets.token_urlsafe(64)[:128]
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
.decode()
.rstrip("=")
)
auth_url = client.get_authorization_url(
redirect_uri=redirect_uri,
state=state,
scope="openid profile email",
code_challenge=code_challenge,
code_challenge_method="S256",
)
# Reset handler state
@@ -266,7 +282,9 @@ def browser_login(port: int = CALLBACK_PORT) -> dict[str, Any]:
raise click.ClickException("No authorization code received.")
# Exchange code for tokens
tokens = client.exchange_code(code=code, redirect_uri=redirect_uri)
tokens = client.exchange_code(
code=code, redirect_uri=redirect_uri, code_verifier=code_verifier
)
client.close()
token_data = {
@@ -316,9 +334,20 @@ def password_login(
if not password:
password = click.prompt("Password", hide_input=True)
# Use ROPC grant to get tokens directly
# The endpoint comes from IAMConfig, never a literal. The hardcoded
# "/oauth/token" that used to be here is not a 404 — IAM serves a
# 200 text/html SPA catch-all for any unregistered path, so this call
# returned a login PAGE and resp.json() blew up on HTML. A wrong path
# here is silent breakage, which is why there is exactly one definition.
config = IAMConfig(
server_url=server_url,
client_id=client_id,
client_secret=client_secret,
organization=org,
application=app,
)
resp = httpx.post(
f"{server_url}/oauth/token",
config.token_endpoint,
data={
"grant_type": "password",
"client_id": client_id,
+7 -2
View File
@@ -36,8 +36,13 @@ dev = [
"ruff>=0.5.0",
]
[project.scripts]
hanzo = "hanzo_cli.cli:main"
# No [project.scripts]. The `hanzo` command belongs to the `hanzo` package and
# to nothing else. Both packages used to declare `hanzo = ...`, and since
# `hanzo` DEPENDS on this one, the winner was decided by pip install order —
# a coin flip between a 56-command CLI and this 10-command one. hanzo-cli kept
# winning, so `pip install hanzo` silently produced a CLI with no `auth`,
# `chat`, `agent`, `cloud`, `dev` or `mcp` at all. This package remains
# importable (`python -m hanzo_cli`) and its groups are mounted by `hanzo`.
[project.urls]
Homepage = "https://hanzo.ai"
+7 -2
View File
@@ -12,18 +12,23 @@ Run:
from __future__ import annotations
import subprocess
import sys
import time
import pytest
HANZO = "hanzo"
# Invoke THIS package's command tree, not whatever `hanzo` resolves to on PATH.
# hanzo_cli no longer installs a `hanzo` script (that name belongs to the
# `hanzo` package), so a bare "hanzo" here would silently exercise a different
# CLI than the one these tests are written against.
HANZO_CMD = [sys.executable, "-m", "hanzo_cli"]
TIMEOUT = 30
def run(args: list[str], check: bool = True) -> subprocess.CompletedProcess:
"""Run a hanzo CLI command and return the result."""
result = subprocess.run(
[HANZO] + args,
HANZO_CMD + args,
capture_output=True,
text=True,
timeout=TIMEOUT,
+9 -5
View File
@@ -7,7 +7,11 @@ from typing import ClassVar
from pydantic import BaseModel, ConfigDict, Field
from hanzo_iam.models import OIDC_USERINFO_PATH
from hanzo_iam.models import (
OIDC_AUTHORIZE_PATH,
OIDC_TOKEN_PATH,
OIDC_USERINFO_PATH,
)
class IAMConfig(BaseModel):
@@ -77,13 +81,13 @@ class IAMConfig(BaseModel):
@property
def token_endpoint(self) -> str:
"""OAuth2 token endpoint URL."""
return f"{self.server_url}/oauth/token"
"""OAuth2 token endpoint URL (HIP-0111 canonical path)."""
return f"{self.server_url}{OIDC_TOKEN_PATH}"
@property
def authorize_endpoint(self) -> str:
"""OAuth2 authorization endpoint URL."""
return f"{self.server_url}/oauth/authorize"
"""OAuth2 authorization endpoint URL (HIP-0111 canonical path)."""
return f"{self.server_url}{OIDC_AUTHORIZE_PATH}"
@property
def userinfo_endpoint(self) -> str:
+2 -1
View File
@@ -35,6 +35,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from hanzo_iam.models import (
OIDC_JWKS_PATH,
OIDC_USERINFO_PATH,
IAMConfig,
JWTClaims,
@@ -115,7 +116,7 @@ def _get_jwks_client() -> PyJWKClient:
if _jwks_client is None:
config = get_config()
jwks_url = f"{config.server_url}/.well-known/jwks.json"
jwks_url = f"{config.server_url}{OIDC_JWKS_PATH}"
_jwks_client = PyJWKClient(jwks_url)
return _jwks_client
+24
View File
@@ -50,6 +50,30 @@ class IAMConfig(BaseModel):
default="", description="JWT verification certificate (PEM)"
)
# HIP-0111 endpoints, derived from the constants above so a caller can never
# hand-assemble a path. This is the IAMConfig the package exports, so these
# have to live here: callers doing `from hanzo_iam import IAMConfig` get
# this class, not the one in config.py.
@property
def authorize_endpoint(self) -> str:
"""OAuth2 authorization endpoint URL."""
return f"{self.server_url.rstrip('/')}{OIDC_AUTHORIZE_PATH}"
@property
def token_endpoint(self) -> str:
"""OAuth2 token endpoint URL."""
return f"{self.server_url.rstrip('/')}{OIDC_TOKEN_PATH}"
@property
def userinfo_endpoint(self) -> str:
"""OIDC UserInfo endpoint URL."""
return f"{self.server_url.rstrip('/')}{OIDC_USERINFO_PATH}"
@property
def jwks_endpoint(self) -> str:
"""OIDC JWKS endpoint URL."""
return f"{self.server_url.rstrip('/')}{OIDC_JWKS_PATH}"
class TokenResponse(BaseModel):
"""OAuth2 token response."""
+11 -1
View File
@@ -1,6 +1,16 @@
"""Hanzo - Complete AI Infrastructure Platform with CLI, Router, MCP, and Agent Runtime."""
__version__ = "0.3.47"
from importlib.metadata import PackageNotFoundError, version as _version
try:
# The installed distribution is the single source of the version. Hardcoding
# it here meant three declarations (pyproject 0.4.4, this file 0.3.47,
# cli.py 0.3.48) that drifted apart, so `hanzo --version` reported a release
# that did not exist and no one could tell what they were actually running.
__version__ = _version("hanzo")
except PackageNotFoundError: # running from a source tree, not installed
__version__ = "0.0.0+dev"
__all__ = ["main", "cli", "__version__"]
from .cli import cli, main
+20 -2
View File
@@ -52,8 +52,8 @@ from .commands import (
)
from .utils.output import console
# Version
__version__ = "0.3.48"
# Version — resolved from the installed distribution, never restated here.
from hanzo import __version__
HANZO_BIN = Path.home() / ".hanzo" / "bin"
@@ -174,6 +174,24 @@ cli.add_command(tasks.tasks_group)
cli.add_command(tools.tools_group)
cli.add_command(vector.vector_group)
# Groups owned by the hanzo_cli package. `hanzo` is the ONE command, so
# anything hanzo_cli implements has to be reachable from here — it no longer
# ships a competing console script. Only the two groups with no equivalent are
# mounted: `hanzo-cli kms` is not, because `hanzo secrets` is a strict superset
# of it (audit/grant/revoke/rollback/rotate/versions on top of the same
# get/list/set/delete), and two commands for one concern is the thing we are
# removing, not adding.
try: # pragma: no cover - optional dependency
from hanzo_cli.cli import main as _hanzo_cli
for _name in ("paas", "bot"):
_group = _hanzo_cli.commands.get(_name)
if _group is not None:
cli.add_command(_group, name=_name)
except ImportError:
pass
# Aliases
cli.add_command(doc.doc_group, name="docdb") # docdb alias for doc
cli.add_command(fn.fn_group, name="fn") # fn alias for function