feat(cli): add k8s kubectl wrapper and remove REPL

- Add `hanzo k8s` command group to hanzo-cli that wraps kubectl with
  Hanzo-managed auth and default namespace. Uses KubectlGroup that
  dynamically proxies unknown subcommands to kubectl (e.g.
  `hanzo k8s get pods` -> `kubectl get pods -n hanzo`).
- Remove REPL mode from hanzo wrapper: bare `hanzo` now shows help
  instead of dropping into interactive REPL.
- Remove hanzo-ai, hanzo-chat, hanzo-repl entry points.
- Make hanzo-cli a core dependency of hanzo (was optional [cli] extra).
- Remove unused typer dep, move openai/anthropic/prompt-toolkit to
  optional extras ([ai], [interactive]).

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-02-21 09:46:50 -08:00
co-authored by Hanzo Dev
parent 1212dd509d
commit 7d3d318e5e
7 changed files with 308 additions and 100 deletions
+3
View File
@@ -8,6 +8,7 @@ Usage:
hanzo iam <subcommand>
hanzo kms <subcommand>
hanzo paas <subcommand>
hanzo k8s <subcommand | kubectl args>
"""
from __future__ import annotations
@@ -117,10 +118,12 @@ def whoami() -> None:
# =========================================================================
from hanzo_cli.iam.commands import iam # noqa: E402
from hanzo_cli.k8s.commands import k8s # noqa: E402
from hanzo_cli.kms.commands import kms # noqa: E402
from hanzo_cli.paas.commands import deploy, paas # noqa: E402
main.add_command(iam)
main.add_command(k8s)
main.add_command(kms)
main.add_command(paas)
+291
View File
@@ -0,0 +1,291 @@
"""Hanzo CLI — Kubernetes management via kubectl passthrough.
Wraps kubectl with Hanzo-managed authentication and sensible defaults.
Unrecognized subcommands pass directly through to kubectl.
Usage:
hanzo k8s auth # Fetch kubeconfig from Hanzo PaaS
hanzo k8s get pods # kubectl get pods -n hanzo
hanzo k8s logs <pod> # kubectl logs <pod> -n hanzo
hanzo k8s apply -f file.yaml # kubectl apply -f file.yaml -n hanzo
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
import click
from rich.console import Console
from rich.table import Table
from hanzo_cli.paas.client import PaaSClient
console = Console()
KUBECONFIG_DIR = Path.home() / ".hanzo" / "k8s"
KUBECONFIG_FILE = KUBECONFIG_DIR / "kubeconfig"
DEFAULT_NAMESPACE = "hanzo"
# ── helpers ────────────────────────────────────────────────────────────
def _find_kubectl() -> str:
"""Locate kubectl or exit with install instructions."""
path = shutil.which("kubectl")
if not path:
console.print(
"[red]kubectl not found.[/red] "
"Install: https://kubernetes.io/docs/tasks/tools/"
)
sys.exit(1)
return path
def _kubeconfig_env() -> dict[str, str]:
"""Return env dict with KUBECONFIG set if the managed file exists."""
env = dict(os.environ)
if KUBECONFIG_FILE.exists():
env["KUBECONFIG"] = str(KUBECONFIG_FILE)
return env
def _kubectl_run(args: list[str], namespace: str | None = None) -> int:
"""Execute kubectl with managed kubeconfig and optional namespace.
Returns the process exit code.
"""
kubectl = _find_kubectl()
cmd = [kubectl]
# Inject default namespace if not already specified
ns_flags = {"--namespace", "-n", "--all-namespaces", "-A"}
if namespace and not ns_flags.intersection(args):
cmd.extend(["--namespace", namespace])
cmd.extend(args)
result = subprocess.run(cmd, env=_kubeconfig_env())
return result.returncode
# ── custom group class ─────────────────────────────────────────────────
class KubectlGroup(click.Group):
"""Click group that forwards unknown subcommands to kubectl.
Known subcommands (auth, info, context, etc.) are handled normally.
Everything else (get, apply, describe, rollout, ...) creates a dynamic
command that passes through to kubectl.
"""
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
# Try builtin commands first
rv = super().get_command(ctx, cmd_name)
if rv is not None:
return rv
# Dynamic kubectl passthrough command
@click.command(
cmd_name,
context_settings={
"ignore_unknown_options": True,
"allow_extra_args": True,
"allow_interspersed_args": False,
},
)
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
@click.pass_context
def kubectl_proxy(ctx: click.Context, args: tuple[str, ...]) -> None:
ns = (ctx.parent.params.get("namespace") if ctx.parent else None) or DEFAULT_NAMESPACE
all_args = [cmd_name] + list(args)
rc = _kubectl_run(all_args, namespace=ns)
ctx.exit(rc)
kubectl_proxy.help = f"kubectl {cmd_name} (passthrough)"
return kubectl_proxy
def resolve_command(
self, ctx: click.Context, args: list[str]
) -> tuple[str | None, click.Command | None, list[str]]:
# Always resolve — get_command handles unknown names via passthrough
cmd_name = args[0] if args else None
if cmd_name is None:
return super().resolve_command(ctx, args)
cmd = self.get_command(ctx, cmd_name)
if cmd is None:
return super().resolve_command(ctx, args)
return cmd_name, cmd, args[1:]
# ── click group ────────────────────────────────────────────────────────
@click.group(cls=KubectlGroup, invoke_without_command=True)
@click.option(
"-n",
"--namespace",
default=None,
help="Kubernetes namespace (default: hanzo).",
)
@click.pass_context
def k8s(ctx: click.Context, namespace: str | None) -> None:
"""Kubernetes management — wraps kubectl with Hanzo auth.
Any unrecognized subcommand is forwarded directly to kubectl with
the managed kubeconfig and default namespace.
\b
Examples
hanzo k8s auth Set up kubeconfig
hanzo k8s get pods kubectl get pods -n hanzo
hanzo k8s -n production get po kubectl get pods -n production
hanzo k8s logs my-pod -f kubectl logs my-pod -f -n hanzo
hanzo k8s apply -f deploy.yaml kubectl apply -f deploy.yaml
"""
ctx.ensure_object(dict)
ctx.obj["namespace"] = namespace or DEFAULT_NAMESPACE
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# ── auth ───────────────────────────────────────────────────────────────
@k8s.command("auth")
@click.option("--force", is_flag=True, help="Overwrite existing kubeconfig.")
def k8s_auth(force: bool) -> None:
"""Authenticate and fetch kubeconfig from Hanzo PaaS.
Exchanges your IAM credentials for cluster access and stores the
kubeconfig at ~/.hanzo/k8s/kubeconfig.
"""
if KUBECONFIG_FILE.exists() and not force:
console.print(f"Kubeconfig exists at [cyan]{KUBECONFIG_FILE}[/cyan]")
console.print("Use [bold]--force[/bold] to overwrite.")
return
console.print("Authenticating with Hanzo PaaS...")
client = PaaSClient.from_auth()
try:
kubeconfig_text = _fetch_kubeconfig(client)
finally:
client.close()
if kubeconfig_text:
KUBECONFIG_DIR.mkdir(parents=True, exist_ok=True)
KUBECONFIG_FILE.write_text(kubeconfig_text)
KUBECONFIG_FILE.chmod(0o600)
console.print(f"[green]Kubeconfig saved to {KUBECONFIG_FILE}[/green]")
console.print("Run [bold]hanzo k8s get pods[/bold] to verify.")
else:
default_kc = Path.home() / ".kube" / "config"
console.print("[yellow]PaaS kubeconfig endpoint not available yet.[/yellow]")
if default_kc.exists():
console.print(f"kubectl will use your default config at {default_kc}")
else:
console.print(
"Configure kubectl manually:\n"
" doctl kubernetes cluster kubeconfig save <cluster-name>"
)
def _fetch_kubeconfig(client: PaaSClient) -> str | None:
"""Try multiple PaaS endpoints to obtain kubeconfig text."""
# 1. Dedicated kubeconfig endpoint
try:
resp = client.http.get("/v1/cluster/kubeconfig")
if resp.status_code == 200:
ct = resp.headers.get("content-type", "")
if "json" in ct:
data = resp.json()
return data.get("kubeconfig", json.dumps(data, indent=2))
return resp.text
except Exception:
pass
# 2. Cluster info — may contain embedded kubeconfig
try:
info = client.cluster_info()
kc = info.get("kubeconfig") or info.get("config")
if kc:
if isinstance(kc, dict):
return json.dumps(kc, indent=2)
return str(kc)
except Exception:
pass
return None
# ── info ───────────────────────────────────────────────────────────────
@k8s.command("info")
def k8s_info() -> None:
"""Show cluster information from Hanzo PaaS."""
client = PaaSClient.from_auth()
try:
info = client.cluster_info()
finally:
client.close()
table = Table(title="Cluster Info")
table.add_column("Field", style="cyan")
table.add_column("Value", style="white")
for key, value in info.items():
display = json.dumps(value, indent=2) if isinstance(value, (dict, list)) else str(value)
table.add_row(key, display)
console.print(table)
# ── context ────────────────────────────────────────────────────────────
@k8s.command("context")
def k8s_context() -> None:
"""Show current kubectl context and configuration status."""
table = Table(title="Kubernetes Config")
table.add_column("Field", style="cyan")
table.add_column("Value", style="white")
if KUBECONFIG_FILE.exists():
table.add_row("Kubeconfig", str(KUBECONFIG_FILE))
else:
default_kc = Path.home() / ".kube" / "config"
if default_kc.exists():
table.add_row("Kubeconfig", f"{default_kc} (system default)")
else:
table.add_row("Kubeconfig", "[red]Not configured[/red]")
table.add_row("Default Namespace", DEFAULT_NAMESPACE)
kubectl = shutil.which("kubectl")
table.add_row("kubectl", kubectl or "[red]Not installed[/red]")
console.print(table)
if kubectl:
env = _kubeconfig_env()
result = subprocess.run(
[kubectl, "config", "current-context"],
capture_output=True,
text=True,
env=env,
)
if result.returncode == 0:
console.print(f"\nCurrent context: [green]{result.stdout.strip()}[/green]")
else:
console.print("\n[yellow]No current context set.[/yellow]")
+12 -20
View File
@@ -8,14 +8,11 @@ authors = [
dependencies = [
"click>=8.1.0",
"rich>=13.0.0",
"typer>=0.9.0",
"prompt-toolkit>=3.0.0",
"httpx>=0.23.0",
"pydantic>=2.0.0",
"pyyaml>=6.0",
"openai>=1.0.0",
"anthropic>=0.25.0",
"qrcode>=7.4.2",
"hanzo-cli>=0.2.0",
"hanzo-kms>=1.1.0",
]
readme = "README.md"
requires-python = ">= 3.12"
@@ -35,30 +32,25 @@ classifiers = [
[project.scripts]
hanzo = "hanzo.cli:main"
hanzo-ai = "hanzo.repl:ai_chat"
hanzo-chat = "hanzo.repl:ai_chat"
hanzo-repl = "hanzo.repl:repl_main"
# Note: hanzo-mcp command is provided by the dedicated hanzo-mcp package
[project.optional-dependencies]
cli = [
"hanzo-cli>=0.2.0",
"hanzo-kms>=1.1.0",
]
all = [
"hanzoai>=1.0.0",
"hanzo-mcp>=0.7.0",
"hanzo-agents>=0.1.0",
"hanzo-network>=0.1.3",
"hanzo-repl>=0.1.0",
"hanzo-memory>=1.0.0",
"hanzo-cli>=0.2.0",
"hanzo-kms>=1.1.0",
# "hanzo-router>=1.74.3", # TODO: Publish hanzo-router to PyPI
"hanzo-aci>=0.2.8",
"prompt-toolkit>=3.0.0",
"openai>=1.0.0",
"anthropic>=0.25.0",
"qrcode>=7.4.2",
]
ai = [
"hanzoai>=1.0.0", # This provides the same as standalone hanzoai package
"hanzoai>=1.0.0",
"openai>=1.0.0",
"anthropic>=0.25.0",
]
router = [
# "hanzo-router>=1.74.3", # TODO: Publish hanzo-router to PyPI
@@ -73,9 +65,9 @@ agents = [
dev = [
"hanzo-aci>=0.2.8",
]
repl = [
"hanzo-repl>=0.1.0",
# "hanzo-router>=1.74.3", # TODO: For AI chat functionality - publish to PyPI
interactive = [
"prompt-toolkit>=3.0.0",
"qrcode>=7.4.2",
]
# Infrastructure extras - simple names
+2 -34
View File
@@ -29,7 +29,6 @@ from .commands import (
jobs,
node,
o11y,
repl,
agent,
cloud,
miner,
@@ -50,10 +49,7 @@ from .commands import (
platform,
git_provider,
)
from .ui.startup import show_startup
from .utils.output import console
from .interactive.repl import HanzoREPL
from .interactive.enhanced_repl import EnhancedHanzoREPL
# Version
__version__ = "0.3.48"
@@ -66,10 +62,7 @@ __version__ = "0.3.48"
@click.option("--config", "-c", type=click.Path(), help="Config file path")
@click.pass_context
def cli(ctx, verbose: bool, json: bool, config: Optional[str]):
"""Hanzo AI - Unified CLI for local, private, and free AI.
Run without arguments to enter interactive mode.
"""
"""Hanzo AI - Unified CLI for infrastructure, deployments, and services."""
# Ensure context object exists
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
@@ -77,32 +70,8 @@ def cli(ctx, verbose: bool, json: bool, config: Optional[str]):
ctx.obj["config"] = config
ctx.obj["console"] = console
# If no subcommand, enter interactive mode or start compute node
if ctx.invoked_subcommand is None:
# Check if we should start as a compute node
import os
if os.environ.get("HANZO_COMPUTE_NODE") == "1":
# Start as a compute node
asyncio.run(start_compute_node(ctx))
else:
# Show startup UI (unless in quiet mode)
if not ctx.obj.get("quiet") and not os.environ.get("HANZO_NO_STARTUP"):
show_startup(minimal=os.environ.get("HANZO_MINIMAL_UI") == "1")
# Enter interactive REPL mode
try:
# Use enhanced REPL if available, otherwise fallback
use_enhanced = os.environ.get("HANZO_ENHANCED_REPL", "1") == "1"
if use_enhanced:
repl = EnhancedHanzoREPL(console=console)
else:
repl = HanzoREPL(console=console)
asyncio.run(repl.run())
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted[/yellow]")
except EOFError:
console.print("\n[yellow]Goodbye![/yellow]")
click.echo(ctx.get_help())
# Register command groups
@@ -135,7 +104,6 @@ cli.add_command(o11y.o11y_group)
cli.add_command(platform.platform_group)
cli.add_command(pubsub.pubsub_group)
cli.add_command(queues.queues_group)
cli.add_command(repl.repl_group)
cli.add_command(router.router_group)
cli.add_command(run.run_group)
cli.add_command(search.search_group)
-1
View File
@@ -32,7 +32,6 @@ __all__ = [
"platform",
"pubsub",
"queues",
"repl",
"router",
"run",
"search",
-45
View File
@@ -1,45 +0,0 @@
"""REPL and AI chat entry points for Hanzo."""
import sys
import click
def ai_chat():
"""Start AI chat interface (hanzo/ai or hanzo/chat)."""
try:
# Use hanzo-router instead of litellm for AI routing
from hanzo_repl.cli import main as repl_main
# Pass AI chat mode
sys.argv = [sys.argv[0], "--mode", "ai"]
repl_main()
except ImportError:
click.echo(
"Error: hanzo-repl or hanzo-router is not installed. Please run: pip install hanzo[ai] or pip install hanzo[all]",
err=True,
)
sys.exit(1)
def repl_main():
"""Start the Hanzo REPL interface (hanzo/repl)."""
try:
from hanzo_repl.cli import main as repl_cli_main
repl_cli_main()
except ImportError:
click.echo(
"Error: hanzo-repl is not installed. Please run: pip install hanzo[all]",
err=True,
)
sys.exit(1)
if __name__ == "__main__":
import os
if os.path.basename(sys.argv[0]) in ["ai", "chat"]:
ai_chat()
else:
repl_main()