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:
zooqueen
2026-02-21 11:54:01 -08:00
parent df010e4acc
commit bcdea21d23
5 changed files with 1039 additions and 405 deletions
+372
View File
@@ -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()
+2
View File
@@ -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
View File
@@ -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()
+546 -68
View File
@@ -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}")