mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(cli): add bot commands, s3 storage, dev/net passthrough
- Add `hanzo bot` command group (status, logs, deploy, env, events) via PaaS API - Replace all storage placeholders with real boto3 S3 API calls to s3.hanzo.ai - Rename `hanzo storage` → `hanzo s3` (storage kept as alias) - Rewrite `hanzo dev` as lazy-install passthrough to @hanzo/dev binary - Rewrite `hanzo net`/`hanzo node` as passthrough to hanzod binary - Remove ~350 lines of inline orchestrator/node code in favor of os.execvp()
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
"""Hanzo CLI — Bot gateway management.
|
||||
|
||||
Manage the Hanzo bot-gateway deployment via PaaS API.
|
||||
Defaults to the Hanzo production bot container.
|
||||
|
||||
Usage:
|
||||
hanzo bot status Show container + pod status
|
||||
hanzo bot logs [--tail N] View recent logs
|
||||
hanzo bot deploy Trigger redeploy
|
||||
hanzo bot env [KEY=VAL ...] Show/set env vars
|
||||
hanzo bot events Show container events
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from hanzo_cli.paas.client import PaaSClient
|
||||
from hanzo_cli.paas.context import resolve
|
||||
|
||||
console = Console()
|
||||
|
||||
# ── defaults ──────────────────────────────────────────────────────────
|
||||
|
||||
# Known Hanzo production bot container context.
|
||||
# These can be overridden via --org / --project / --env flags.
|
||||
DEFAULT_BOT_ORG = "698cda6739f65183b3009313"
|
||||
DEFAULT_BOT_PROJECT = "698cda6739f65183b3009318"
|
||||
DEFAULT_BOT_ENV = "698cda6739f65183b300931c"
|
||||
DEFAULT_BOT_NAME = "bot"
|
||||
|
||||
|
||||
# ── shared options ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _org_option(fn):
|
||||
return click.option(
|
||||
"--org", default=None, help="Organization ID (default: Hanzo)."
|
||||
)(fn)
|
||||
|
||||
|
||||
def _project_option(fn):
|
||||
return click.option(
|
||||
"--project", default=None, help="Project ID (default: Platform)."
|
||||
)(fn)
|
||||
|
||||
|
||||
def _env_option(fn):
|
||||
return click.option(
|
||||
"--env", "env_id", default=None, help="Environment ID (default: production)."
|
||||
)(fn)
|
||||
|
||||
|
||||
def _name_option(fn):
|
||||
return click.option(
|
||||
"--name", "-n", default=DEFAULT_BOT_NAME,
|
||||
help="Container name (default: bot).",
|
||||
)(fn)
|
||||
|
||||
|
||||
def _resolve_bot(
|
||||
org: str | None,
|
||||
project: str | None,
|
||||
env_id: str | None,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Resolve org/project/env, falling back to bot defaults."""
|
||||
ctx_org, ctx_proj, ctx_env = resolve(org, project, env_id)
|
||||
return (
|
||||
ctx_org or DEFAULT_BOT_ORG,
|
||||
ctx_proj or DEFAULT_BOT_PROJECT,
|
||||
ctx_env or DEFAULT_BOT_ENV,
|
||||
)
|
||||
|
||||
|
||||
def _find_container(
|
||||
client: PaaSClient,
|
||||
org_id: str,
|
||||
project_id: str,
|
||||
env_id: str,
|
||||
name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Look up a container by name."""
|
||||
containers = client.list_containers(org_id, project_id, env_id)
|
||||
items = containers if isinstance(containers, list) else containers.get("data", containers)
|
||||
for c in items:
|
||||
if c.get("iid") == name or c.get("name") == name or c.get("slug") == name:
|
||||
return c
|
||||
console.print(f"[red]Bot container '{name}' not found.[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
# ── group ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@click.group()
|
||||
def bot() -> None:
|
||||
"""Manage the Hanzo bot-gateway deployment."""
|
||||
|
||||
|
||||
# ── status ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@bot.command("status")
|
||||
@_name_option
|
||||
@_org_option
|
||||
@_project_option
|
||||
@_env_option
|
||||
def bot_status(
|
||||
name: str,
|
||||
org: str | None,
|
||||
project: str | None,
|
||||
env_id: str | None,
|
||||
) -> None:
|
||||
"""Show bot container status and pods."""
|
||||
org_id, project_id, env_id = _resolve_bot(org, project, env_id)
|
||||
|
||||
client = PaaSClient.from_auth()
|
||||
try:
|
||||
container = _find_container(client, org_id, project_id, env_id, name)
|
||||
cid = str(container.get("_id", container.get("id")))
|
||||
|
||||
# Container info
|
||||
table = Table(title=f"Bot: {name}")
|
||||
table.add_column("Field", style="cyan")
|
||||
table.add_column("Value", style="white")
|
||||
table.add_row("ID", cid)
|
||||
table.add_row("Type", container.get("type", "—"))
|
||||
|
||||
pipeline = container.get("pipelineStatus", "—")
|
||||
if isinstance(pipeline, dict):
|
||||
pipeline = pipeline.get("status", str(pipeline))
|
||||
table.add_row("Pipeline", str(pipeline))
|
||||
|
||||
reg = container.get("registry", {})
|
||||
if isinstance(reg, dict) and reg.get("imageUrl"):
|
||||
table.add_row("Image", reg["imageUrl"])
|
||||
|
||||
status = container.get("status", {})
|
||||
if isinstance(status, dict):
|
||||
table.add_row("Desired", str(status.get("desiredReplicas", "—")))
|
||||
table.add_row("Ready", str(status.get("readyReplicas", "—")))
|
||||
table.add_row("Available", str(status.get("availableReplicas", "—")))
|
||||
console.print(table)
|
||||
|
||||
# Pods
|
||||
try:
|
||||
pods_data = client.get_container_pods(org_id, project_id, env_id, cid)
|
||||
pods = pods_data if isinstance(pods_data, list) else pods_data.get("data", [])
|
||||
if pods:
|
||||
pod_table = Table(title="Pods")
|
||||
pod_table.add_column("Name", style="cyan")
|
||||
pod_table.add_column("Status", style="green")
|
||||
pod_table.add_column("Restarts", style="yellow", justify="right")
|
||||
pod_table.add_column("Age", style="dim")
|
||||
for p in pods:
|
||||
pod_table.add_row(
|
||||
p.get("name", "—"),
|
||||
p.get("status", "—"),
|
||||
str(p.get("restartCount", 0)),
|
||||
p.get("age", "—"),
|
||||
)
|
||||
console.print(pod_table)
|
||||
else:
|
||||
console.print("[yellow]No pods found.[/yellow]")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ── logs ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@bot.command("logs")
|
||||
@click.option("--tail", "-t", type=int, default=100, help="Number of lines to show.")
|
||||
@_name_option
|
||||
@_org_option
|
||||
@_project_option
|
||||
@_env_option
|
||||
def bot_logs(
|
||||
tail: int,
|
||||
name: str,
|
||||
org: str | None,
|
||||
project: str | None,
|
||||
env_id: str | None,
|
||||
) -> None:
|
||||
"""Show bot container logs."""
|
||||
org_id, project_id, env_id = _resolve_bot(org, project, env_id)
|
||||
|
||||
client = PaaSClient.from_auth()
|
||||
try:
|
||||
container = _find_container(client, org_id, project_id, env_id, name)
|
||||
cid = str(container.get("_id", container.get("id")))
|
||||
logs_data = client.get_container_logs(org_id, project_id, env_id, cid)
|
||||
|
||||
if isinstance(logs_data, dict) and "logs" in logs_data:
|
||||
pod_logs = logs_data["logs"]
|
||||
if isinstance(pod_logs, list):
|
||||
for pod in pod_logs:
|
||||
if isinstance(pod, dict):
|
||||
pod_name = pod.get("podName", "?")
|
||||
lines = pod.get("logs", [])
|
||||
if len(pod_logs) > 1:
|
||||
click.echo(click.style(f"--- {pod_name} ---", fg="cyan"))
|
||||
shown = lines[-tail:] if tail else lines
|
||||
for line in shown:
|
||||
if line:
|
||||
click.echo(line)
|
||||
else:
|
||||
click.echo(str(pod))
|
||||
else:
|
||||
click.echo(str(pod_logs))
|
||||
elif isinstance(logs_data, str):
|
||||
lines = logs_data.splitlines()
|
||||
for line in lines[-tail:]:
|
||||
click.echo(line)
|
||||
elif isinstance(logs_data, list):
|
||||
for line in logs_data[-tail:]:
|
||||
click.echo(line)
|
||||
else:
|
||||
click.echo(json.dumps(logs_data, indent=2))
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ── deploy ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@bot.command("deploy")
|
||||
@_name_option
|
||||
@_org_option
|
||||
@_project_option
|
||||
@_env_option
|
||||
def bot_deploy(
|
||||
name: str,
|
||||
org: str | None,
|
||||
project: str | None,
|
||||
env_id: str | None,
|
||||
) -> None:
|
||||
"""Trigger a redeploy of the bot container."""
|
||||
org_id, project_id, env_id = _resolve_bot(org, project, env_id)
|
||||
|
||||
client = PaaSClient.from_auth()
|
||||
try:
|
||||
container = _find_container(client, org_id, project_id, env_id, name)
|
||||
cid = str(container.get("_id", container.get("id")))
|
||||
client.redeploy_container(org_id, project_id, env_id, cid)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
console.print(f"[green]Redeployment triggered for '{name}'.[/green]")
|
||||
|
||||
|
||||
# ── env ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@bot.command("env")
|
||||
@click.argument("vars", nargs=-1)
|
||||
@_name_option
|
||||
@_org_option
|
||||
@_project_option
|
||||
@_env_option
|
||||
def bot_env(
|
||||
vars: tuple[str, ...],
|
||||
name: str,
|
||||
org: str | None,
|
||||
project: str | None,
|
||||
env_id: str | None,
|
||||
) -> None:
|
||||
"""Show or set bot environment variables.
|
||||
|
||||
Without arguments, shows current vars. With KEY=VAL pairs, sets them.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
hanzo bot env Show current vars
|
||||
hanzo bot env NODE_ENV=production Set a var
|
||||
hanzo bot env BOT_TOKEN=xxx LOG_LEVEL=debug Set multiple
|
||||
"""
|
||||
org_id, project_id, env_id = _resolve_bot(org, project, env_id)
|
||||
|
||||
client = PaaSClient.from_auth()
|
||||
try:
|
||||
container = _find_container(client, org_id, project_id, env_id, name)
|
||||
cid = str(container.get("_id", container.get("id")))
|
||||
|
||||
if not vars:
|
||||
# Show current vars
|
||||
variables = container.get("variables", [])
|
||||
if not variables:
|
||||
console.print("[yellow]No environment variables set.[/yellow]")
|
||||
return
|
||||
table = Table(title=f"Env vars: {name}")
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Value", style="dim")
|
||||
for v in variables:
|
||||
val = v.get("value", "")
|
||||
masked = f"{val[:4]}***" if len(val) > 4 else val
|
||||
table.add_row(v.get("name", "—"), masked)
|
||||
console.print(table)
|
||||
else:
|
||||
# Set vars
|
||||
new_vars = []
|
||||
for kv in vars:
|
||||
if "=" not in kv:
|
||||
console.print(f"[red]Invalid format: '{kv}'. Use KEY=VALUE.[/red]")
|
||||
raise SystemExit(1)
|
||||
k, v = kv.split("=", 1)
|
||||
new_vars.append({"name": k, "value": v})
|
||||
|
||||
# Merge with existing
|
||||
existing = {v["name"]: v["value"] for v in container.get("variables", [])}
|
||||
for nv in new_vars:
|
||||
existing[nv["name"]] = nv["value"]
|
||||
|
||||
merged = [{"name": k, "value": v} for k, v in existing.items()]
|
||||
container["variables"] = merged
|
||||
client.update_container(org_id, project_id, env_id, cid, container)
|
||||
console.print(f"[green]Set {len(new_vars)} variable(s) on '{name}'.[/green]")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ── events ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@bot.command("events")
|
||||
@_name_option
|
||||
@_org_option
|
||||
@_project_option
|
||||
@_env_option
|
||||
def bot_events(
|
||||
name: str,
|
||||
org: str | None,
|
||||
project: str | None,
|
||||
env_id: str | None,
|
||||
) -> None:
|
||||
"""Show bot container events."""
|
||||
org_id, project_id, env_id = _resolve_bot(org, project, env_id)
|
||||
|
||||
client = PaaSClient.from_auth()
|
||||
try:
|
||||
container = _find_container(client, org_id, project_id, env_id, name)
|
||||
cid = str(container.get("_id", container.get("id")))
|
||||
events_data = client.get_container_events(org_id, project_id, env_id, cid)
|
||||
|
||||
events = events_data if isinstance(events_data, list) else events_data.get("data", [])
|
||||
if not events:
|
||||
console.print("[yellow]No events found.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title=f"Events: {name}")
|
||||
table.add_column("Type", style="cyan")
|
||||
table.add_column("Reason", style="yellow")
|
||||
table.add_column("Message", style="white")
|
||||
table.add_column("Age", style="dim")
|
||||
|
||||
for e in events:
|
||||
table.add_row(
|
||||
e.get("type", "—"),
|
||||
e.get("reason", "—"),
|
||||
e.get("message", "—"),
|
||||
e.get("age", e.get("lastTimestamp", "—")),
|
||||
)
|
||||
console.print(table)
|
||||
finally:
|
||||
client.close()
|
||||
@@ -117,11 +117,13 @@ def whoami() -> None:
|
||||
# Register subgroups
|
||||
# =========================================================================
|
||||
|
||||
from hanzo_cli.bot.commands import bot # noqa: E402
|
||||
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(bot)
|
||||
main.add_command(iam)
|
||||
main.add_command(k8s)
|
||||
main.add_command(kms)
|
||||
|
||||
+119
-337
@@ -2,10 +2,10 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import asyncio
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
@@ -54,6 +54,64 @@ from .utils.output import console
|
||||
# Version
|
||||
__version__ = "0.3.48"
|
||||
|
||||
HANZO_BIN = Path.home() / ".hanzo" / "bin"
|
||||
|
||||
|
||||
def _find_binary(*names: str) -> str | None:
|
||||
"""Search PATH and ~/.hanzo/bin for the first matching binary."""
|
||||
for name in names:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
candidate = HANZO_BIN / name
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def _auto_install_npm(package: str) -> bool:
|
||||
"""Install an npm package globally. Returns True on success."""
|
||||
npm = shutil.which("npm") or shutil.which("pnpm")
|
||||
if not npm:
|
||||
return False
|
||||
try:
|
||||
console.print(f"[cyan]Installing {package}...[/cyan]")
|
||||
subprocess.run(
|
||||
[npm, "install", "-g", package],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
console.print(f"[green]Installed {package}[/green]")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def _auto_install_pip(package: str) -> bool:
|
||||
"""Install a Python package via uv or pip. Returns True on success."""
|
||||
uv = shutil.which("uv")
|
||||
if uv:
|
||||
cmd = [uv, "pip", "install", package]
|
||||
else:
|
||||
pip = shutil.which("pip3") or shutil.which("pip")
|
||||
if not pip:
|
||||
return False
|
||||
cmd = [pip, "install", package]
|
||||
try:
|
||||
console.print(f"[cyan]Installing {package}...[/cyan]")
|
||||
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=120)
|
||||
console.print(f"[green]Installed {package}[/green]")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def _passthrough(binary: str, args: tuple[str, ...]) -> None:
|
||||
"""Replace the current process with the given binary."""
|
||||
os.execvp(binary, [binary] + list(args))
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.version_option(version=__version__, prog_name="hanzo")
|
||||
@@ -108,7 +166,8 @@ cli.add_command(router.router_group)
|
||||
cli.add_command(run.run_group)
|
||||
cli.add_command(search.search_group)
|
||||
cli.add_command(secrets.secrets_group)
|
||||
cli.add_command(storage.storage_group)
|
||||
cli.add_command(storage.storage_group) # primary: hanzo s3
|
||||
cli.add_command(storage.storage_group, name="storage") # alias: hanzo storage
|
||||
cli.add_command(tasks.tasks_group)
|
||||
cli.add_command(tools.tools_group)
|
||||
cli.add_command(vector.vector_group)
|
||||
@@ -126,6 +185,7 @@ cli.add_command(fn.fn_group, name="fn") # fn alias for function
|
||||
@click.pass_context
|
||||
def ask(ctx, prompt: tuple, model: str, local: bool):
|
||||
"""Quick question to AI (alias for 'hanzo chat --once')."""
|
||||
import asyncio
|
||||
prompt_text = " ".join(prompt)
|
||||
asyncio.run(chat.ask_once(ctx, prompt_text, model, local))
|
||||
|
||||
@@ -242,354 +302,76 @@ cli.add_command(run.run_group, name="deploy")
|
||||
@click.pass_context
|
||||
def serve(ctx, name: str, port: int):
|
||||
"""Start local AI node (alias for 'hanzo node start')."""
|
||||
import asyncio
|
||||
asyncio.run(node.start_node(ctx, name, port))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--name", "-n", help="Node name (auto-generated if not provided)")
|
||||
@click.option("--port", "-p", default=52415, help="Node port (default: 52415 for hanzo/net)")
|
||||
@click.option("--network", default="local", help="Network to join (mainnet/testnet/local)")
|
||||
@click.option("--models", "-m", multiple=True, help="Models to serve (e.g., llama-3.2-3b)")
|
||||
@click.option("--max-jobs", type=int, default=10, help="Max concurrent jobs")
|
||||
@click.pass_context
|
||||
def net(ctx, name: str, port: int, network: str, models: tuple, max_jobs: int):
|
||||
"""Start the Hanzo Network distributed AI compute node."""
|
||||
try:
|
||||
asyncio.run(start_compute_node(ctx, name, port, network, models, max_jobs))
|
||||
except KeyboardInterrupt:
|
||||
# Already handled in start_compute_node
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--name", "-n", help="Node name (auto-generated if not provided)")
|
||||
@click.option("--port", "-p", default=52415, help="Node port (default: 52415 for hanzo/net)")
|
||||
@click.option("--network", default="local", help="Network to join (mainnet/testnet/local)")
|
||||
@click.option("--models", "-m", multiple=True, help="Models to serve (e.g., llama-3.2-3b)")
|
||||
@click.option("--max-jobs", type=int, default=10, help="Max concurrent jobs")
|
||||
@click.pass_context
|
||||
def node(ctx, name: str, port: int, network: str, models: tuple, max_jobs: int):
|
||||
"""Alias for 'hanzo net' - Start as a compute node for the Hanzo network."""
|
||||
try:
|
||||
asyncio.run(start_compute_node(ctx, name, port, network, models, max_jobs))
|
||||
except KeyboardInterrupt:
|
||||
# Already handled in start_compute_node
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--workspace", default="~/.hanzo/dev", help="Workspace directory")
|
||||
@click.option(
|
||||
"--orchestrator",
|
||||
default="gpt-5",
|
||||
help="Orchestrator: gpt-5, router:gpt-4o, direct:claude, codex, gpt-5-pro-codex, cost-optimized",
|
||||
@cli.command(
|
||||
context_settings={
|
||||
"ignore_unknown_options": True,
|
||||
"allow_extra_args": True,
|
||||
"allow_interspersed_args": False,
|
||||
},
|
||||
)
|
||||
@click.option(
|
||||
"--orchestrator-mode",
|
||||
type=click.Choice(["router", "direct", "codex", "hybrid", "local"]),
|
||||
default=None,
|
||||
help="Force orchestrator mode (router via hanzo-router, direct API, codex, hybrid, local)",
|
||||
)
|
||||
@click.option(
|
||||
"--router-endpoint",
|
||||
default=None,
|
||||
help="Hanzo router endpoint (default: http://localhost:4000)",
|
||||
)
|
||||
@click.option("--claude-path", help="Path to Claude Code executable")
|
||||
@click.option("--monitor", is_flag=True, help="Start in monitor mode")
|
||||
@click.option("--repl", is_flag=True, help="Start REPL interface (default)")
|
||||
@click.option("--instances", type=int, default=2, help="Number of worker agents")
|
||||
@click.option("--mcp-tools", is_flag=True, default=True, help="Enable all MCP tools")
|
||||
@click.option("--network-mode", is_flag=True, default=True, help="Network agents together")
|
||||
@click.option("--guardrails", is_flag=True, default=True, help="Enable code quality guardrails")
|
||||
@click.option("--use-network/--no-network", default=True, help="Use hanzo-network if available")
|
||||
@click.option(
|
||||
"--use-hanzo-net",
|
||||
is_flag=True,
|
||||
help="Use hanzo/net for local AI (auto-enabled with local: models)",
|
||||
)
|
||||
@click.option(
|
||||
"--hanzo-net-port",
|
||||
type=int,
|
||||
default=52415,
|
||||
help="Port for hanzo/net (default: 52415)",
|
||||
)
|
||||
@click.pass_context
|
||||
def dev(
|
||||
ctx,
|
||||
workspace: str,
|
||||
orchestrator: str,
|
||||
orchestrator_mode: str,
|
||||
router_endpoint: str,
|
||||
claude_path: str,
|
||||
monitor: bool,
|
||||
repl: bool,
|
||||
instances: int,
|
||||
mcp_tools: bool,
|
||||
network_mode: bool,
|
||||
guardrails: bool,
|
||||
use_network: bool,
|
||||
use_hanzo_net: bool,
|
||||
hanzo_net_port: int,
|
||||
):
|
||||
"""Start Hanzo Dev - AI Coding OS with configurable orchestrator.
|
||||
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
||||
def net(args: tuple[str, ...]):
|
||||
"""Start a Hanzo Network compute node (delegates to hanzod).
|
||||
|
||||
This creates a multi-agent system where:
|
||||
- Configurable orchestrator (GPT-5, GPT-4, Claude, or LOCAL) manages the network
|
||||
- Local AI via hanzo/net for cost-effective orchestration
|
||||
- Worker agents (Claude + local) handle code implementation
|
||||
- Critic agents review and improve code (System 2 thinking)
|
||||
- Cost-optimized routing (local models for simple tasks)
|
||||
- All agents can use MCP tools
|
||||
- Agents can call each other recursively
|
||||
- Guardrails prevent code degradation
|
||||
- Auto-recovery from failures
|
||||
All arguments are passed through to the hanzod binary.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
hanzo dev # GPT-5 orchestrator (default)
|
||||
hanzo dev --orchestrator gpt-4 # GPT-4 orchestrator
|
||||
hanzo dev --orchestrator claude-3-5-sonnet # Claude orchestrator
|
||||
hanzo dev --orchestrator local:llama3.2 # Local Llama 3.2 via hanzo/net
|
||||
hanzo dev --use-hanzo-net # Enable local AI workers
|
||||
hanzo dev --instances 4 # More worker agents
|
||||
hanzo dev --monitor # Auto-monitor and restart mode
|
||||
hanzo net Start compute node
|
||||
hanzo net --port 8080 Start on custom port
|
||||
hanzo net --help Show hanzod help
|
||||
"""
|
||||
from .dev import run_dev_orchestrator
|
||||
from .orchestrator_config import OrchestratorMode, get_orchestrator_config
|
||||
|
||||
# Get orchestrator configuration
|
||||
orch_config = get_orchestrator_config(orchestrator)
|
||||
|
||||
# Override mode if specified
|
||||
if orchestrator_mode:
|
||||
orch_config.mode = OrchestratorMode(orchestrator_mode)
|
||||
|
||||
# Override router endpoint if specified
|
||||
if router_endpoint and orch_config.router:
|
||||
orch_config.router.endpoint = router_endpoint
|
||||
|
||||
# Auto-enable hanzo net if using local orchestrator
|
||||
if orchestrator.startswith("local:") or orch_config.mode == OrchestratorMode.LOCAL:
|
||||
use_hanzo_net = True
|
||||
|
||||
# Show configuration
|
||||
console.print(f"[bold cyan]Orchestrator Configuration[/bold cyan]")
|
||||
console.print(f" Mode: {orch_config.mode.value}")
|
||||
console.print(f" Primary Model: {orch_config.primary_model}")
|
||||
if orch_config.router:
|
||||
console.print(f" Router Endpoint: {orch_config.router.endpoint}")
|
||||
if orch_config.codex:
|
||||
console.print(f" Codex Model: {orch_config.codex.model}")
|
||||
console.print(f" Cost Optimization: {'Enabled' if orch_config.enable_cost_optimization else 'Disabled'}")
|
||||
console.print()
|
||||
|
||||
asyncio.run(
|
||||
run_dev_orchestrator(
|
||||
workspace=workspace,
|
||||
orchestrator_model=orchestrator,
|
||||
orchestrator_config=orch_config, # Pass the config
|
||||
claude_path=claude_path,
|
||||
monitor=monitor,
|
||||
repl=repl or not monitor, # Default to REPL if not monitoring
|
||||
instances=instances,
|
||||
mcp_tools=mcp_tools,
|
||||
network_mode=network_mode,
|
||||
guardrails=guardrails,
|
||||
use_network=use_network,
|
||||
use_hanzo_net=use_hanzo_net,
|
||||
hanzo_net_port=hanzo_net_port,
|
||||
console=ctx.obj.get("console", console),
|
||||
)
|
||||
)
|
||||
binary = _find_binary("hanzod")
|
||||
if not binary:
|
||||
console.print("[yellow]hanzod not found. Installing...[/yellow]")
|
||||
if _auto_install_npm("@hanzo/hanzod"):
|
||||
binary = _find_binary("hanzod")
|
||||
if not binary:
|
||||
console.print("[red]Failed to install hanzod.[/red]")
|
||||
console.print("Install manually: curl -fsSL https://hanzo.sh | bash")
|
||||
raise SystemExit(1)
|
||||
_passthrough(binary, args)
|
||||
|
||||
|
||||
async def start_compute_node(
|
||||
ctx,
|
||||
name: str = None,
|
||||
port: int = 52415,
|
||||
network: str = "mainnet",
|
||||
models: tuple = None,
|
||||
max_jobs: int = 10,
|
||||
):
|
||||
"""Start this instance as a compute node using hanzo/net."""
|
||||
from .utils.net_check import check_net_installation
|
||||
@cli.command(
|
||||
context_settings={
|
||||
"ignore_unknown_options": True,
|
||||
"allow_extra_args": True,
|
||||
"allow_interspersed_args": False,
|
||||
},
|
||||
)
|
||||
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
||||
def dev(args: tuple[str, ...]):
|
||||
"""Hanzo Dev — AI coding assistant (delegates to @hanzo/dev).
|
||||
|
||||
console = ctx.obj.get("console", Console())
|
||||
All arguments are passed through to the hanzo-dev binary.
|
||||
|
||||
console.print("[bold cyan]Starting Hanzo Net Compute Node[/bold cyan]")
|
||||
console.print(f"Network: {network}")
|
||||
console.print(f"Port: {port}")
|
||||
\b
|
||||
Examples:
|
||||
hanzo dev Start Hanzo Dev
|
||||
hanzo dev mcp list List MCP servers
|
||||
hanzo dev mcp add <name> Add MCP server
|
||||
hanzo dev mcp remove <name> Remove MCP server
|
||||
hanzo dev --help Show dev binary help
|
||||
"""
|
||||
binary = _find_binary("dev", "hanzo-dev")
|
||||
if not binary:
|
||||
console.print("[yellow]hanzo-dev not found. Installing @hanzo/dev...[/yellow]")
|
||||
if _auto_install_npm("@hanzo/dev"):
|
||||
binary = _find_binary("dev", "hanzo-dev")
|
||||
if not binary:
|
||||
console.print("[red]Failed to install @hanzo/dev.[/red]")
|
||||
console.print("Install manually: npm install -g @hanzo/dev")
|
||||
console.print(" or: curl -fsSL https://hanzo.sh | bash")
|
||||
raise SystemExit(1)
|
||||
_passthrough(binary, args)
|
||||
|
||||
# Check hanzo/net availability
|
||||
is_available, net_path, python_exe = check_net_installation()
|
||||
|
||||
if not is_available:
|
||||
console.print("[red]Error:[/red] hanzo-net is not installed")
|
||||
console.print("\nTo install hanzo-net from PyPI:")
|
||||
console.print(" pip install hanzo-net")
|
||||
console.print("\nOr for development, clone from GitHub:")
|
||||
console.print(" git clone https://github.com/hanzoai/net.git ~/work/hanzo/net")
|
||||
console.print(" cd ~/work/hanzo/net && pip install -e .")
|
||||
return
|
||||
|
||||
try:
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
# Use the checked net_path and python_exe
|
||||
if not net_path:
|
||||
# net is installed as a package
|
||||
console.print("[green]✓[/green] Using installed hanzo/net")
|
||||
|
||||
# Set up sys.argv for net's argparse
|
||||
original_argv = sys.argv.copy()
|
||||
try:
|
||||
# Build argv for net
|
||||
sys.argv = ["hanzo-net"] # Program name
|
||||
|
||||
# Add options
|
||||
if port != 52415:
|
||||
sys.argv.extend(["--chatgpt-api-port", str(port)])
|
||||
if name:
|
||||
sys.argv.extend(["--node-id", name])
|
||||
if network != "local":
|
||||
sys.argv.extend(["--discovery-module", network])
|
||||
if models:
|
||||
sys.argv.extend(["--default-model", models[0]])
|
||||
|
||||
# Import and run net
|
||||
from net.main import run as net_run
|
||||
|
||||
console.print(f"\n[green]✓[/green] Node initialized")
|
||||
console.print(f" Port: {port}")
|
||||
console.print(f" Models: {', '.join(models) if models else 'auto-detect'}")
|
||||
console.print("\n[bold green]Hanzo Net is running![/bold green]")
|
||||
console.print("WebUI: http://localhost:52415")
|
||||
console.print("API: http://localhost:52415/v1/chat/completions")
|
||||
console.print("\nPress Ctrl+C to stop\n")
|
||||
|
||||
# Set up signal handlers for async version
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
def async_signal_handler(signum, frame):
|
||||
console.print("\n[yellow]Stopping hanzo net...[/yellow]")
|
||||
stop_event.set()
|
||||
|
||||
signal.signal(signal.SIGINT, async_signal_handler)
|
||||
signal.signal(signal.SIGTERM, async_signal_handler)
|
||||
|
||||
# Run net with proper signal handling
|
||||
try:
|
||||
net_task = asyncio.create_task(net_run())
|
||||
stop_task = asyncio.create_task(stop_event.wait())
|
||||
|
||||
# Wait for either net to complete or stop signal
|
||||
done, pending = await asyncio.wait([net_task, stop_task], return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
# Cancel pending tasks
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Check if we stopped due to signal
|
||||
if stop_task in done:
|
||||
console.print("[green]✓[/green] Node stopped gracefully")
|
||||
except asyncio.CancelledError:
|
||||
console.print("[yellow]Cancelled[/yellow]")
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
else:
|
||||
# Run from source directory using the detected python_exe
|
||||
console.print(f"[green]✓[/green] Using hanzo/net from {net_path}")
|
||||
if python_exe != sys.executable:
|
||||
console.print(f"[green]✓[/green] Using hanzo/net venv")
|
||||
else:
|
||||
console.print("[yellow]⚠[/yellow] Using system Python")
|
||||
|
||||
# Change to net directory and run
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(net_path)
|
||||
|
||||
# Set up environment
|
||||
env = os.environ.copy()
|
||||
if models:
|
||||
env["NET_MODELS"] = ",".join(models)
|
||||
if name:
|
||||
env["NET_NODE_NAME"] = name
|
||||
env["PYTHONPATH"] = os.path.join(net_path, "src") + ":" + env.get("PYTHONPATH", "")
|
||||
|
||||
console.print(f"\n[green]✓[/green] Starting net node")
|
||||
console.print(f" Port: {port}")
|
||||
console.print(f" Models: {', '.join(models) if models else 'auto-detect'}")
|
||||
console.print("\n[bold green]Hanzo Net is running![/bold green]")
|
||||
console.print("WebUI: http://localhost:52415")
|
||||
console.print("API: http://localhost:52415/v1/chat/completions")
|
||||
console.print("\nPress Ctrl+C to stop\n")
|
||||
|
||||
# Build command line args
|
||||
cmd_args = [python_exe, "-m", "net.main"]
|
||||
if port != 52415:
|
||||
cmd_args.extend(["--chatgpt-api-port", str(port)])
|
||||
if name:
|
||||
cmd_args.extend(["--node-id", name])
|
||||
if network != "local":
|
||||
cmd_args.extend(["--discovery-module", network])
|
||||
if models:
|
||||
cmd_args.extend(["--default-model", models[0]])
|
||||
|
||||
# Run net command with detected python in a more signal-friendly way
|
||||
# Create new process group for better signal handling
|
||||
process = subprocess.Popen(
|
||||
cmd_args,
|
||||
env=env,
|
||||
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
||||
)
|
||||
|
||||
# Set up signal handlers to forward to subprocess group
|
||||
def signal_handler(signum, frame):
|
||||
if process.poll() is None: # Process is still running
|
||||
console.print("\n[yellow]Stopping hanzo net...[/yellow]")
|
||||
try:
|
||||
# Send signal to entire process group
|
||||
if hasattr(os, "killpg"):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
else:
|
||||
process.terminate()
|
||||
process.wait(timeout=5) # Wait up to 5 seconds
|
||||
except subprocess.TimeoutExpired:
|
||||
console.print("[yellow]Force stopping...[/yellow]")
|
||||
if hasattr(os, "killpg"):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
else:
|
||||
process.kill()
|
||||
process.wait()
|
||||
except ProcessLookupError:
|
||||
pass # Process already terminated
|
||||
raise KeyboardInterrupt
|
||||
|
||||
# Register signal handlers
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Wait for process to complete
|
||||
returncode = process.wait()
|
||||
|
||||
if returncode != 0 and returncode != -2: # -2 is Ctrl+C
|
||||
console.print(f"[red]Net exited with code {returncode}[/red]")
|
||||
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Shutting down node...[/yellow]")
|
||||
console.print("[green]✓[/green] Node stopped")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error starting compute node: {e}[/red]")
|
||||
cli.add_command(net, name="node") # hanzo node = hanzo net
|
||||
|
||||
|
||||
@cli.command()
|
||||
|
||||
@@ -1,42 +1,156 @@
|
||||
"""Hanzo Storage - Object storage CLI.
|
||||
"""Hanzo Storage — S3-compatible object storage CLI.
|
||||
|
||||
S3-compatible object storage with CDN.
|
||||
Connects to s3.hanzo.ai (S3-compatible API) using stored credentials.
|
||||
|
||||
Environment variables:
|
||||
HANZO_S3_ENDPOINT — S3 endpoint (default: https://s3.hanzo.ai)
|
||||
HANZO_S3_REGION — Region (default: us-east-1)
|
||||
AWS_ACCESS_KEY_ID — S3 access key (or from hanzo auth)
|
||||
AWS_SECRET_ACCESS_KEY — S3 secret key (or from hanzo auth)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.progress import Progress, TextColumn, SpinnerColumn
|
||||
|
||||
from ..utils.output import console
|
||||
|
||||
S3_ENDPOINT = os.getenv("HANZO_S3_ENDPOINT", "https://s3.hanzo.ai")
|
||||
S3_REGION = os.getenv("HANZO_S3_REGION", "us-east-1")
|
||||
|
||||
@click.group(name="storage")
|
||||
|
||||
def _get_s3_client():
|
||||
"""Create an S3 client connected to s3.hanzo.ai."""
|
||||
try:
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
except ImportError:
|
||||
console.print("[red]boto3 not installed.[/red] Run: pip install 'hanzo[storage]'")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Try to get credentials from env or hanzo auth
|
||||
access_key = os.getenv("AWS_ACCESS_KEY_ID")
|
||||
secret_key = os.getenv("AWS_SECRET_ACCESS_KEY")
|
||||
|
||||
if not access_key or not secret_key:
|
||||
# Try loading from hanzo auth token
|
||||
token_file = Path.home() / ".hanzo" / "auth" / "s3_credentials.json"
|
||||
if token_file.exists():
|
||||
try:
|
||||
creds = json.loads(token_file.read_text())
|
||||
access_key = creds.get("access_key_id")
|
||||
secret_key = creds.get("secret_access_key")
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
if not access_key or not secret_key:
|
||||
console.print("[red]S3 credentials not configured.[/red]")
|
||||
console.print("Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or run:")
|
||||
console.print(" hanzo storage auth")
|
||||
raise SystemExit(1)
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=S3_ENDPOINT,
|
||||
region_name=S3_REGION,
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""Format bytes to human-readable size."""
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if abs(size_bytes) < 1024:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024
|
||||
return f"{size_bytes:.1f} PB"
|
||||
|
||||
|
||||
def _parse_s3_path(path: str) -> tuple[str, str]:
|
||||
"""Parse 's3://bucket/key' or 'bucket/key' into (bucket, key)."""
|
||||
path = path.removeprefix("s3://")
|
||||
parts = path.split("/", 1)
|
||||
bucket = parts[0]
|
||||
key = parts[1] if len(parts) > 1 else ""
|
||||
return bucket, key
|
||||
|
||||
|
||||
@click.group(name="s3")
|
||||
def storage_group():
|
||||
"""Hanzo Storage - S3-compatible object storage.
|
||||
"""Hanzo S3 — S3-compatible object storage (hanzo.space / s3.hanzo.ai).
|
||||
|
||||
\b
|
||||
Buckets:
|
||||
hanzo storage buckets list # List buckets
|
||||
hanzo storage buckets create # Create bucket
|
||||
hanzo storage buckets delete # Delete bucket
|
||||
hanzo s3 buckets list List buckets
|
||||
hanzo s3 buckets create Create bucket
|
||||
hanzo s3 buckets delete Delete bucket
|
||||
|
||||
\b
|
||||
Objects:
|
||||
hanzo storage ls # List objects
|
||||
hanzo storage cp # Copy files (upload/download)
|
||||
hanzo storage mv # Move/rename objects
|
||||
hanzo storage rm # Delete objects
|
||||
hanzo storage sync # Sync directories
|
||||
hanzo s3 ls List objects
|
||||
hanzo s3 cp Copy files (upload/download)
|
||||
hanzo s3 mv Move/rename objects
|
||||
hanzo s3 rm Delete objects
|
||||
hanzo s3 sync Sync directories
|
||||
|
||||
\b
|
||||
Sharing:
|
||||
hanzo storage presign # Generate presigned URL
|
||||
hanzo storage public # Make object public
|
||||
hanzo s3 presign Generate presigned URL
|
||||
hanzo s3 public Make object public
|
||||
|
||||
\b
|
||||
Endpoint: s3.hanzo.ai (S3-compatible)
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Auth
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@storage_group.command(name="auth")
|
||||
@click.option("--access-key", prompt="Access Key ID", help="S3 access key ID")
|
||||
@click.option(
|
||||
"--secret-key",
|
||||
prompt="Secret Access Key",
|
||||
hide_input=True,
|
||||
help="S3 secret access key",
|
||||
)
|
||||
@click.option("--endpoint", default=S3_ENDPOINT, help="S3 endpoint URL")
|
||||
def storage_auth(access_key: str, secret_key: str, endpoint: str):
|
||||
"""Configure S3 credentials for hanzo storage.
|
||||
|
||||
Credentials are stored at ~/.hanzo/auth/s3_credentials.json
|
||||
"""
|
||||
creds_dir = Path.home() / ".hanzo" / "auth"
|
||||
creds_dir.mkdir(parents=True, exist_ok=True)
|
||||
creds_file = creds_dir / "s3_credentials.json"
|
||||
creds_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"access_key_id": access_key,
|
||||
"secret_access_key": secret_key,
|
||||
"endpoint": endpoint,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
creds_file.chmod(0o600)
|
||||
console.print(f"[green]S3 credentials saved to {creds_file}[/green]")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bucket Management
|
||||
# ============================================================================
|
||||
@@ -49,44 +163,71 @@ def buckets():
|
||||
|
||||
|
||||
@buckets.command(name="list")
|
||||
@click.option("--project", "-p", help="Project ID")
|
||||
def buckets_list(project: str):
|
||||
@click.option("--json-output", "json_out", is_flag=True, help="Output as JSON")
|
||||
def buckets_list(json_out: bool):
|
||||
"""List all buckets."""
|
||||
table = Table(title="Buckets", box=box.ROUNDED)
|
||||
s3 = _get_s3_client()
|
||||
response = s3.list_buckets()
|
||||
bucket_list = response.get("Buckets", [])
|
||||
|
||||
if json_out:
|
||||
click.echo(json.dumps(
|
||||
[{"name": b["Name"], "created": b["CreationDate"].isoformat()} for b in bucket_list],
|
||||
indent=2,
|
||||
))
|
||||
return
|
||||
|
||||
if not bucket_list:
|
||||
console.print("[dim]No buckets found. Create one with 'hanzo storage buckets create'[/dim]")
|
||||
return
|
||||
|
||||
table = Table(title=f"Buckets ({len(bucket_list)})", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Region", style="white")
|
||||
table.add_column("Objects", style="green")
|
||||
table.add_column("Size", style="yellow")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for b in bucket_list:
|
||||
table.add_row(b["Name"], b["CreationDate"].strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No buckets found. Create one with 'hanzo storage buckets create'[/dim]")
|
||||
|
||||
|
||||
@buckets.command(name="create")
|
||||
@click.argument("name")
|
||||
@click.option("--region", "-r", default="us-east-1", help="Bucket region")
|
||||
@click.option("--public", is_flag=True, help="Make bucket publicly readable")
|
||||
@click.option("--versioning", is_flag=True, help="Enable versioning")
|
||||
def buckets_create(name: str, region: str, public: bool, versioning: bool):
|
||||
@click.option("--region", "-r", default=S3_REGION, help="Bucket region")
|
||||
def buckets_create(name: str, region: str):
|
||||
"""Create a bucket."""
|
||||
console.print(f"[green]✓[/green] Bucket '{name}' created")
|
||||
console.print(f" Region: {region}")
|
||||
console.print(f" Public: {'Yes' if public else 'No'}")
|
||||
console.print(f" Versioning: {'Enabled' if versioning else 'Disabled'}")
|
||||
s3 = _get_s3_client()
|
||||
create_config = {}
|
||||
if region and region != "us-east-1":
|
||||
create_config["CreateBucketConfiguration"] = {"LocationConstraint": region}
|
||||
s3.create_bucket(Bucket=name, **create_config)
|
||||
console.print(f"[green]Bucket '{name}' created.[/green]")
|
||||
|
||||
|
||||
@buckets.command(name="delete")
|
||||
@click.argument("name")
|
||||
@click.option("--force", "-f", is_flag=True, help="Delete even if not empty")
|
||||
def buckets_delete(name: str, force: bool):
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
||||
def buckets_delete(name: str, force: bool, yes: bool):
|
||||
"""Delete a bucket."""
|
||||
if not force:
|
||||
from rich.prompt import Confirm
|
||||
if not yes:
|
||||
click.confirm(f"Delete bucket '{name}'?", abort=True)
|
||||
|
||||
if not Confirm.ask(f"[red]Delete bucket '{name}'?[/red]"):
|
||||
return
|
||||
console.print(f"[green]✓[/green] Bucket '{name}' deleted")
|
||||
s3 = _get_s3_client()
|
||||
|
||||
if force:
|
||||
# Delete all objects first
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
for page in paginator.paginate(Bucket=name):
|
||||
objects = page.get("Contents", [])
|
||||
if objects:
|
||||
s3.delete_objects(
|
||||
Bucket=name,
|
||||
Delete={"Objects": [{"Key": o["Key"]} for o in objects]},
|
||||
)
|
||||
|
||||
s3.delete_bucket(Bucket=name)
|
||||
console.print(f"[green]Bucket '{name}' deleted.[/green]")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -98,95 +239,432 @@ def buckets_delete(name: str, force: bool):
|
||||
@click.argument("path", default="")
|
||||
@click.option("--recursive", "-r", is_flag=True, help="List recursively")
|
||||
@click.option("--human", "-h", is_flag=True, help="Human-readable sizes")
|
||||
def storage_ls(path: str, recursive: bool, human: bool):
|
||||
@click.option("--json-output", "json_out", is_flag=True, help="Output as JSON")
|
||||
def storage_ls(path: str, recursive: bool, human: bool, json_out: bool):
|
||||
"""List objects in a bucket.
|
||||
|
||||
\b
|
||||
PATH format: bucket/prefix or s3://bucket/prefix
|
||||
|
||||
\b
|
||||
Examples:
|
||||
hanzo storage ls mybucket List top-level objects
|
||||
hanzo storage ls mybucket/images/ -r List recursively
|
||||
hanzo storage ls s3://mybucket -h Human-readable sizes
|
||||
"""
|
||||
if not path:
|
||||
console.print("[dim]Usage: hanzo storage ls <bucket>[/prefix][/dim]")
|
||||
return
|
||||
|
||||
bucket, prefix = _parse_s3_path(path)
|
||||
s3 = _get_s3_client()
|
||||
|
||||
all_objects = []
|
||||
all_prefixes = []
|
||||
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
params: dict[str, Any] = {"Bucket": bucket}
|
||||
if prefix:
|
||||
params["Prefix"] = prefix
|
||||
if not recursive:
|
||||
params["Delimiter"] = "/"
|
||||
|
||||
for page in paginator.paginate(**params):
|
||||
all_objects.extend(page.get("Contents", []))
|
||||
all_prefixes.extend(page.get("CommonPrefixes", []))
|
||||
|
||||
if json_out:
|
||||
items = []
|
||||
for p in all_prefixes:
|
||||
items.append({"key": p["Prefix"], "type": "directory"})
|
||||
for o in all_objects:
|
||||
items.append({
|
||||
"key": o["Key"],
|
||||
"size": o["Size"],
|
||||
"modified": o["LastModified"].isoformat(),
|
||||
"type": "file",
|
||||
})
|
||||
click.echo(json.dumps(items, indent=2))
|
||||
return
|
||||
|
||||
if not all_objects and not all_prefixes:
|
||||
console.print("[dim]No objects found[/dim]")
|
||||
return
|
||||
|
||||
table = Table(box=box.SIMPLE)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Size", style="green", justify="right")
|
||||
table.add_column("Modified", style="dim")
|
||||
|
||||
for p in all_prefixes:
|
||||
table.add_row(f"[bold]{p['Prefix']}[/bold]", "DIR", "")
|
||||
|
||||
for o in all_objects:
|
||||
size_str = _format_size(o["Size"]) if human else str(o["Size"])
|
||||
modified = o["LastModified"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
table.add_row(o["Key"], size_str, modified)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No objects found[/dim]")
|
||||
total = sum(o["Size"] for o in all_objects)
|
||||
console.print(
|
||||
f"[dim]{len(all_objects)} object(s), {len(all_prefixes)} prefix(es), "
|
||||
f"total {_format_size(total)}[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@storage_group.command(name="cp")
|
||||
@click.argument("source")
|
||||
@click.argument("dest")
|
||||
@click.option("--recursive", "-r", is_flag=True, help="Copy recursively")
|
||||
@click.option("--progress", is_flag=True, default=True, help="Show progress")
|
||||
def storage_cp(source: str, dest: str, recursive: bool, progress: bool):
|
||||
def storage_cp(source: str, dest: str, recursive: bool):
|
||||
"""Copy files to/from storage.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
hanzo storage cp file.txt mybucket/ # Upload
|
||||
hanzo storage cp mybucket/file.txt ./ # Download
|
||||
hanzo storage cp -r ./dir mybucket/prefix/ # Upload directory
|
||||
hanzo storage cp file.txt mybucket/ Upload
|
||||
hanzo storage cp mybucket/file.txt ./ Download
|
||||
hanzo storage cp -r ./dir mybucket/prefix/ Upload directory
|
||||
hanzo storage cp mybucket/a.txt mybucket/b.txt Server-side copy
|
||||
"""
|
||||
console.print(f"[green]✓[/green] Copied {source} → {dest}")
|
||||
s3 = _get_s3_client()
|
||||
is_s3_src = source.startswith("s3://") or "/" in source and not os.path.exists(source)
|
||||
is_s3_dst = dest.startswith("s3://") or "/" in dest and not os.path.exists(os.path.dirname(dest) or ".")
|
||||
|
||||
# Heuristic: if source exists locally, it's an upload
|
||||
if os.path.exists(source) and not source.startswith("s3://"):
|
||||
is_s3_src = False
|
||||
|
||||
if not is_s3_src and is_s3_dst:
|
||||
# Upload
|
||||
bucket, key = _parse_s3_path(dest)
|
||||
local_path = Path(source)
|
||||
|
||||
if recursive and local_path.is_dir():
|
||||
files = list(local_path.rglob("*"))
|
||||
files = [f for f in files if f.is_file()]
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task(f"Uploading {len(files)} files...", total=len(files))
|
||||
for f in files:
|
||||
rel = f.relative_to(local_path)
|
||||
obj_key = f"{key}{rel}" if key else str(rel)
|
||||
s3.upload_file(str(f), bucket, obj_key)
|
||||
progress.advance(task)
|
||||
console.print(f"[green]Uploaded {len(files)} files to {bucket}/{key}[/green]")
|
||||
else:
|
||||
if not key or key.endswith("/"):
|
||||
key = key + local_path.name
|
||||
s3.upload_file(str(local_path), bucket, key)
|
||||
console.print(f"[green]Uploaded {source} -> s3://{bucket}/{key}[/green]")
|
||||
|
||||
elif is_s3_src and not is_s3_dst:
|
||||
# Download
|
||||
bucket, key = _parse_s3_path(source)
|
||||
local_path = Path(dest)
|
||||
|
||||
if recursive:
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
objects = []
|
||||
for page in paginator.paginate(Bucket=bucket, Prefix=key):
|
||||
objects.extend(page.get("Contents", []))
|
||||
|
||||
if not objects:
|
||||
console.print("[yellow]No objects found to download.[/yellow]")
|
||||
return
|
||||
|
||||
local_path.mkdir(parents=True, exist_ok=True)
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task(f"Downloading {len(objects)} files...", total=len(objects))
|
||||
for obj in objects:
|
||||
rel_key = obj["Key"].removeprefix(key).lstrip("/")
|
||||
if not rel_key:
|
||||
continue
|
||||
dest_file = local_path / rel_key
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
s3.download_file(bucket, obj["Key"], str(dest_file))
|
||||
progress.advance(task)
|
||||
console.print(f"[green]Downloaded {len(objects)} files to {dest}[/green]")
|
||||
else:
|
||||
if local_path.is_dir():
|
||||
filename = key.rsplit("/", 1)[-1] if "/" in key else key
|
||||
local_path = local_path / filename
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
s3.download_file(bucket, key, str(local_path))
|
||||
console.print(f"[green]Downloaded s3://{bucket}/{key} -> {local_path}[/green]")
|
||||
|
||||
elif is_s3_src and is_s3_dst:
|
||||
# Server-side copy
|
||||
src_bucket, src_key = _parse_s3_path(source)
|
||||
dst_bucket, dst_key = _parse_s3_path(dest)
|
||||
s3.copy_object(
|
||||
CopySource={"Bucket": src_bucket, "Key": src_key},
|
||||
Bucket=dst_bucket,
|
||||
Key=dst_key,
|
||||
)
|
||||
console.print(f"[green]Copied s3://{src_bucket}/{src_key} -> s3://{dst_bucket}/{dst_key}[/green]")
|
||||
else:
|
||||
console.print("[red]At least one path must be an S3 path (bucket/key or s3://bucket/key).[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@storage_group.command(name="mv")
|
||||
@click.argument("source")
|
||||
@click.argument("dest")
|
||||
def storage_mv(source: str, dest: str):
|
||||
"""Move or rename objects."""
|
||||
console.print(f"[green]✓[/green] Moved {source} → {dest}")
|
||||
"""Move or rename objects in storage.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
hanzo storage mv mybucket/old.txt mybucket/new.txt Rename
|
||||
hanzo storage mv mybucket/a.txt other-bucket/a.txt Move between buckets
|
||||
"""
|
||||
s3 = _get_s3_client()
|
||||
src_bucket, src_key = _parse_s3_path(source)
|
||||
dst_bucket, dst_key = _parse_s3_path(dest)
|
||||
|
||||
# Copy then delete
|
||||
s3.copy_object(
|
||||
CopySource={"Bucket": src_bucket, "Key": src_key},
|
||||
Bucket=dst_bucket,
|
||||
Key=dst_key,
|
||||
)
|
||||
s3.delete_object(Bucket=src_bucket, Key=src_key)
|
||||
console.print(f"[green]Moved s3://{src_bucket}/{src_key} -> s3://{dst_bucket}/{dst_key}[/green]")
|
||||
|
||||
|
||||
@storage_group.command(name="rm")
|
||||
@click.argument("path")
|
||||
@click.option("--recursive", "-r", is_flag=True, help="Delete recursively")
|
||||
@click.option("--force", "-f", is_flag=True, help="Skip confirmation")
|
||||
def storage_rm(path: str, recursive: bool, force: bool):
|
||||
"""Delete objects."""
|
||||
if not force and not recursive:
|
||||
from rich.prompt import Confirm
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
||||
def storage_rm(path: str, recursive: bool, yes: bool):
|
||||
"""Delete objects from storage.
|
||||
|
||||
if not Confirm.ask(f"[red]Delete '{path}'?[/red]"):
|
||||
\b
|
||||
Examples:
|
||||
hanzo storage rm mybucket/file.txt Delete single object
|
||||
hanzo storage rm -r mybucket/prefix/ Delete all under prefix
|
||||
"""
|
||||
bucket, key = _parse_s3_path(path)
|
||||
s3 = _get_s3_client()
|
||||
|
||||
if recursive:
|
||||
# Count objects first
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
objects = []
|
||||
for page in paginator.paginate(Bucket=bucket, Prefix=key):
|
||||
objects.extend(page.get("Contents", []))
|
||||
|
||||
if not objects:
|
||||
console.print("[yellow]No objects found to delete.[/yellow]")
|
||||
return
|
||||
console.print(f"[green]✓[/green] Deleted {path}")
|
||||
|
||||
if not yes:
|
||||
click.confirm(f"Delete {len(objects)} object(s) under '{path}'?", abort=True)
|
||||
|
||||
# Delete in batches of 1000 (S3 limit)
|
||||
for i in range(0, len(objects), 1000):
|
||||
batch = objects[i : i + 1000]
|
||||
s3.delete_objects(
|
||||
Bucket=bucket,
|
||||
Delete={"Objects": [{"Key": o["Key"]} for o in batch]},
|
||||
)
|
||||
console.print(f"[green]Deleted {len(objects)} object(s).[/green]")
|
||||
else:
|
||||
if not yes:
|
||||
click.confirm(f"Delete '{path}'?", abort=True)
|
||||
s3.delete_object(Bucket=bucket, Key=key)
|
||||
console.print(f"[green]Deleted s3://{bucket}/{key}[/green]")
|
||||
|
||||
|
||||
@storage_group.command(name="sync")
|
||||
@click.argument("source")
|
||||
@click.argument("dest")
|
||||
@click.option("--delete", is_flag=True, help="Delete files not in source")
|
||||
@click.option("--delete", "delete_extra", is_flag=True, help="Delete files not in source")
|
||||
@click.option("--dry-run", is_flag=True, help="Show what would be done")
|
||||
def storage_sync(source: str, dest: str, delete: bool, dry_run: bool):
|
||||
def storage_sync(source: str, dest: str, delete_extra: bool, dry_run: bool):
|
||||
"""Sync directories with storage.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
hanzo storage sync ./build mybucket/assets/ # Upload
|
||||
hanzo storage sync mybucket/assets/ ./local/ # Download
|
||||
hanzo storage sync ./build mybucket/assets/ Upload (sync local -> S3)
|
||||
hanzo storage sync mybucket/assets/ ./local/ Download (sync S3 -> local)
|
||||
"""
|
||||
if dry_run:
|
||||
console.print("[dim]Dry run - no changes made[/dim]")
|
||||
console.print(f"[green]✓[/green] Synced {source} → {dest}")
|
||||
s3 = _get_s3_client()
|
||||
src_local = os.path.exists(source) and not source.startswith("s3://")
|
||||
|
||||
if src_local:
|
||||
# Upload sync: local -> S3
|
||||
bucket, prefix = _parse_s3_path(dest)
|
||||
local_path = Path(source)
|
||||
|
||||
# Get local files
|
||||
local_files = {}
|
||||
for f in local_path.rglob("*"):
|
||||
if f.is_file():
|
||||
rel = str(f.relative_to(local_path))
|
||||
local_files[rel] = f
|
||||
|
||||
# Get remote files
|
||||
remote_files: dict[str, dict] = {}
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
||||
for obj in page.get("Contents", []):
|
||||
rel_key = obj["Key"].removeprefix(prefix).lstrip("/")
|
||||
if rel_key:
|
||||
remote_files[rel_key] = obj
|
||||
|
||||
# Determine what to upload
|
||||
to_upload = []
|
||||
for rel, f in local_files.items():
|
||||
if rel not in remote_files:
|
||||
to_upload.append(rel)
|
||||
else:
|
||||
# Compare by size (quick check)
|
||||
if f.stat().st_size != remote_files[rel]["Size"]:
|
||||
to_upload.append(rel)
|
||||
|
||||
to_delete = []
|
||||
if delete_extra:
|
||||
to_delete = [k for k in remote_files if k not in local_files]
|
||||
|
||||
if dry_run:
|
||||
for rel in to_upload:
|
||||
console.print(f" [green]upload[/green] {rel}")
|
||||
for rel in to_delete:
|
||||
console.print(f" [red]delete[/red] {rel}")
|
||||
console.print(f"[dim]Would upload {len(to_upload)}, delete {len(to_delete)}[/dim]")
|
||||
return
|
||||
|
||||
for rel in to_upload:
|
||||
obj_key = f"{prefix}{rel}" if prefix else rel
|
||||
s3.upload_file(str(local_files[rel]), bucket, obj_key)
|
||||
|
||||
if to_delete:
|
||||
for i in range(0, len(to_delete), 1000):
|
||||
batch = to_delete[i : i + 1000]
|
||||
s3.delete_objects(
|
||||
Bucket=bucket,
|
||||
Delete={"Objects": [{"Key": f"{prefix}{k}"} for k in batch]},
|
||||
)
|
||||
|
||||
console.print(
|
||||
f"[green]Synced: {len(to_upload)} uploaded, "
|
||||
f"{len(to_delete)} deleted[/green]"
|
||||
)
|
||||
else:
|
||||
# Download sync: S3 -> local
|
||||
bucket, prefix = _parse_s3_path(source)
|
||||
local_path = Path(dest)
|
||||
local_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get remote files
|
||||
remote_files: dict[str, dict] = {}
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
||||
for obj in page.get("Contents", []):
|
||||
rel_key = obj["Key"].removeprefix(prefix).lstrip("/")
|
||||
if rel_key:
|
||||
remote_files[rel_key] = obj
|
||||
|
||||
# Get local files
|
||||
local_files_set = set()
|
||||
for f in local_path.rglob("*"):
|
||||
if f.is_file():
|
||||
local_files_set.add(str(f.relative_to(local_path)))
|
||||
|
||||
to_download = []
|
||||
for rel, obj in remote_files.items():
|
||||
dest_file = local_path / rel
|
||||
if not dest_file.exists():
|
||||
to_download.append(rel)
|
||||
elif dest_file.stat().st_size != obj["Size"]:
|
||||
to_download.append(rel)
|
||||
|
||||
to_delete = []
|
||||
if delete_extra:
|
||||
to_delete = [k for k in local_files_set if k not in remote_files]
|
||||
|
||||
if dry_run:
|
||||
for rel in to_download:
|
||||
console.print(f" [green]download[/green] {rel}")
|
||||
for rel in to_delete:
|
||||
console.print(f" [red]delete[/red] {rel}")
|
||||
console.print(f"[dim]Would download {len(to_download)}, delete {len(to_delete)}[/dim]")
|
||||
return
|
||||
|
||||
for rel in to_download:
|
||||
dest_file = local_path / rel
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
s3.download_file(bucket, remote_files[rel]["Key"], str(dest_file))
|
||||
|
||||
for rel in to_delete:
|
||||
(local_path / rel).unlink()
|
||||
|
||||
console.print(
|
||||
f"[green]Synced: {len(to_download)} downloaded, "
|
||||
f"{len(to_delete)} deleted[/green]"
|
||||
)
|
||||
|
||||
|
||||
@storage_group.command(name="presign")
|
||||
@click.argument("path")
|
||||
@click.option("--expires", "-e", default="1h", help="Expiration time (e.g., 1h, 7d)")
|
||||
@click.option("--expires", "-e", default=3600, type=int, help="Expiration in seconds (default: 3600)")
|
||||
@click.option("--method", "-m", default="GET", type=click.Choice(["GET", "PUT"]))
|
||||
def storage_presign(path: str, expires: str, method: str):
|
||||
"""Generate a presigned URL."""
|
||||
console.print(f"[cyan]Presigned URL ({method}, expires {expires}):[/cyan]")
|
||||
console.print(f"https://storage.hanzo.ai/{path}?signature=...")
|
||||
def storage_presign(path: str, expires: int, method: str):
|
||||
"""Generate a presigned URL for an object.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
hanzo storage presign mybucket/file.txt 1-hour GET URL
|
||||
hanzo storage presign mybucket/file.txt -e 86400 24-hour URL
|
||||
hanzo storage presign mybucket/upload.txt -m PUT Upload URL
|
||||
"""
|
||||
bucket, key = _parse_s3_path(path)
|
||||
s3 = _get_s3_client()
|
||||
|
||||
client_method = "get_object" if method == "GET" else "put_object"
|
||||
url = s3.generate_presigned_url(
|
||||
ClientMethod=client_method,
|
||||
Params={"Bucket": bucket, "Key": key},
|
||||
ExpiresIn=expires,
|
||||
)
|
||||
console.print(f"[cyan]Presigned URL ({method}, expires in {expires}s):[/cyan]")
|
||||
click.echo(url)
|
||||
|
||||
|
||||
@storage_group.command(name="public")
|
||||
@click.argument("path")
|
||||
@click.option("--recursive", "-r", is_flag=True, help="Apply to all objects in prefix")
|
||||
def storage_public(path: str, recursive: bool):
|
||||
"""Make object(s) publicly accessible."""
|
||||
console.print(f"[green]✓[/green] Made '{path}' public")
|
||||
console.print(f" URL: https://cdn.hanzo.ai/{path}")
|
||||
"""Make object(s) publicly accessible via CDN.
|
||||
|
||||
Sets the ACL to public-read and prints the CDN URL.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
hanzo storage public mybucket/image.png Single object
|
||||
hanzo storage public -r mybucket/assets/ All under prefix
|
||||
"""
|
||||
bucket, key = _parse_s3_path(path)
|
||||
s3 = _get_s3_client()
|
||||
|
||||
if recursive:
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
count = 0
|
||||
for page in paginator.paginate(Bucket=bucket, Prefix=key):
|
||||
for obj in page.get("Contents", []):
|
||||
s3.put_object_acl(Bucket=bucket, Key=obj["Key"], ACL="public-read")
|
||||
count += 1
|
||||
console.print(f"[green]Made {count} object(s) public.[/green]")
|
||||
console.print(f" CDN: https://cdn.hanzo.ai/{bucket}/{key}")
|
||||
else:
|
||||
s3.put_object_acl(Bucket=bucket, Key=key, ACL="public-read")
|
||||
console.print(f"[green]Made '{key}' public.[/green]")
|
||||
console.print(f" URL: https://cdn.hanzo.ai/{bucket}/{key}")
|
||||
|
||||
Reference in New Issue
Block a user