mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,7 +32,6 @@ __all__ = [
|
||||
"platform",
|
||||
"pubsub",
|
||||
"queues",
|
||||
"repl",
|
||||
"router",
|
||||
"run",
|
||||
"search",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user