fix(lint): run black formatter on all 69 unformatted files

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-02-21 14:49:49 -08:00
co-authored by Hanzo Dev
parent ad65de44e1
commit 964a72bd24
69 changed files with 2391 additions and 697 deletions
+14 -4
View File
@@ -125,7 +125,9 @@ class BaseAgent(ABC, Generic[TContext, TResult]):
# Log start
if context and hasattr(context, "log"):
await context.log(f"Starting {self.name} with model {self.config.model}")
await context.log(
f"Starting {self.name} with model {self.config.model}"
)
# Execute with retries
result = await self._execute_with_retries(prompt, context, env, **kwargs)
@@ -143,7 +145,11 @@ class BaseAgent(ABC, Generic[TContext, TResult]):
except Exception as e:
self._end_time = datetime.now()
duration = (self._end_time - self._start_time).total_seconds() if self._start_time else None
duration = (
(self._end_time - self._start_time).total_seconds()
if self._start_time
else None
)
logger.error(f"Agent {self.name} failed: {e}")
@@ -225,7 +231,9 @@ class BaseAgent(ABC, Generic[TContext, TResult]):
if attempt < self.config.max_retries - 1:
await asyncio.sleep(2**attempt)
raise Exception(f"All {self.config.max_retries} attempts failed. Last error: {last_error}")
raise Exception(
f"All {self.config.max_retries} attempts failed. Last error: {last_error}"
)
@abstractmethod
async def _execute_impl(
@@ -318,7 +326,9 @@ class CLIAgent(BaseAgent[TContext, str]):
)
except asyncio.TimeoutError:
process.kill()
raise asyncio.TimeoutError(f"Command timed out after {self.config.timeout} seconds")
raise asyncio.TimeoutError(
f"Command timed out after {self.config.timeout} seconds"
)
# Check for errors
if process.returncode != 0:
+28 -8
View File
@@ -55,7 +55,11 @@ class BatchTask:
@property
def success(self) -> bool:
"""Check if task succeeded."""
return self.status == "completed" and self.result and getattr(self.result, "success", True)
return (
self.status == "completed"
and self.result
and getattr(self.result, "success", True)
)
def get_error(self) -> Optional[str]:
"""Get error message from direct error or result."""
@@ -157,7 +161,9 @@ class BatchConfig:
if not config.consensus_mode and not config.critic_mode:
agent_match = re.search(r"agent:(\w+)", command)
if agent_match:
config.agent_model = agent_match.group(1) # Will be resolved in __post_init__
config.agent_model = agent_match.group(
1
) # Will be resolved in __post_init__
# Parse worktree option
worktree_match = re.search(r"worktree:(true|false)", command)
@@ -214,7 +220,9 @@ class BatchOrchestrator:
self._task_counter += 1
return f"task_{self._task_counter:04d}"
async def _setup_worktree(self, task_id: str, config: BatchConfig) -> Optional[Path]:
async def _setup_worktree(
self, task_id: str, config: BatchConfig
) -> Optional[Path]:
"""Setup git worktree for parallel editing.
Args:
@@ -503,7 +511,9 @@ class BatchOrchestrator:
"success": True,
}
else:
logger.warning(f"No API client configured - returning empty response for {model}")
logger.warning(
f"No API client configured - returning empty response for {model}"
)
return {
"model": model,
"response": "",
@@ -530,7 +540,9 @@ class BatchOrchestrator:
# If consensus reached, combine insights
if consensus_result["consensus_reached"] and successful_responses:
combined = "\n\n".join([f"[{r['model']}]: {r['response']}" for r in successful_responses])
combined = "\n\n".join(
[f"[{r['model']}]: {r['response']}" for r in successful_responses]
)
consensus_result["combined_response"] = combined
return consensus_result
@@ -620,7 +632,9 @@ Provide your critical analysis:"""
return {
"critic_chain": reviews,
"final_review": (reviews[-1]["review"] if reviews and "review" in reviews[-1] else None),
"final_review": (
reviews[-1]["review"] if reviews and "review" in reviews[-1] else None
),
"models_used": models,
"chain_length": len(reviews),
}
@@ -697,7 +711,11 @@ Provide your critical analysis:"""
if "review" in review:
console.print(
Panel(
(review["review"][:500] + "..." if len(review["review"]) > 500 else review["review"]),
(
review["review"][:500] + "..."
if len(review["review"]) > 500
else review["review"]
),
title=f"[cyan]Critic {review['iteration']}: {review['model']}[/cyan]",
)
)
@@ -772,7 +790,9 @@ Provide your critical analysis:"""
del self.active_tasks[task.id]
# Generate summary
total_duration = sum(t.duration() or 0 for t in self.completed_tasks + self.failed_tasks)
total_duration = sum(
t.duration() or 0 for t in self.completed_tasks + self.failed_tasks
)
summary = {
"total_tasks": len(tasks),
+52 -11
View File
@@ -186,6 +186,7 @@ cli.add_command(fn.fn_group, name="fn") # fn alias for function
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))
@@ -207,7 +208,9 @@ def log(query: str, source: str, follow: bool, level: str, limit: int):
hanzo log -f -s my-api # Tail logs
"""
if follow:
console.print(f"[cyan]Tailing log{' for ' + source if source else ''}...[/cyan]")
console.print(
f"[cyan]Tailing log{' for ' + source if source else ''}...[/cyan]"
)
console.print("[dim]Press Ctrl+C to stop[/dim]")
elif query:
console.print(f"[cyan]Searching log for '{query}'...[/cyan]")
@@ -229,7 +232,9 @@ def metric(query: str, service: str, range: str):
hanzo metric "http_requests_total" # Query specific metric
hanzo metric -s my-api -r 24h # Service metric for 24h
"""
console.print(f"[cyan]Metric{' for ' + service if service else ''} (last {range}):[/cyan]")
console.print(
f"[cyan]Metric{' for ' + service if service else ''} (last {range}):[/cyan]"
)
console.print("[dim]No metric found[/dim]")
@@ -249,7 +254,9 @@ def trace(trace_id: str, service: str, min_duration: str):
if trace_id:
console.print(f"[cyan]Trace {trace_id}:[/cyan]")
else:
console.print(f"[cyan]Recent trace{' for ' + service if service else ''}:[/cyan]")
console.print(
f"[cyan]Recent trace{' for ' + service if service else ''}:[/cyan]"
)
console.print("[dim]No trace found[/dim]")
@@ -271,7 +278,14 @@ def login(ctx, email, password, api_key, web, headless):
hanzo login -k sk-xxx # Direct API key authentication
hanzo login -e [email protected] # Email/password login
"""
ctx.invoke(auth.login, email=email, password=password, api_key=api_key, web=web, headless=headless)
ctx.invoke(
auth.login,
email=email,
password=password,
api_key=api_key,
web=web,
headless=headless,
)
@cli.command()
@@ -303,6 +317,7 @@ cli.add_command(run.run_group, name="deploy")
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))
@@ -418,7 +433,9 @@ def doctor(ctx, json_output: bool):
console.print("[bold]Python Tools (uv):[/bold]")
try:
result_uv = subprocess.run(["uv", "tool", "list"], capture_output=True, text=True, timeout=10)
result_uv = subprocess.run(
["uv", "tool", "list"], capture_output=True, text=True, timeout=10
)
if result_uv.returncode == 0:
table = Table(show_header=True, header_style="bold")
table.add_column("Package", style="cyan")
@@ -432,7 +449,14 @@ def doctor(ctx, json_output: bool):
name, version = parts[0], parts[1]
path = shutil.which(name) or f"~/.local/bin/{name}"
table.add_row(name, version, path)
tools_found.append({"name": name, "version": version, "path": path, "source": "uv"})
tools_found.append(
{
"name": name,
"version": version,
"path": path,
"source": "uv",
}
)
if tools_found:
console.print(table)
@@ -466,8 +490,14 @@ def doctor(ctx, json_output: bool):
if path:
ai_found = True
try:
ver_result = subprocess.run([cmd, "--version"], capture_output=True, text=True, timeout=5)
version = ver_result.stdout.strip().split("\n")[0] if ver_result.returncode == 0 else "?"
ver_result = subprocess.run(
[cmd, "--version"], capture_output=True, text=True, timeout=5
)
version = (
ver_result.stdout.strip().split("\n")[0]
if ver_result.returncode == 0
else "?"
)
except Exception:
version = "?"
ai_table.add_row(name, version, path)
@@ -533,7 +563,13 @@ def update(ctx, upgrade_all: bool, force: bool, packages: tuple):
# Default packages to update
default_packages = ["hanzo", "hanzo-mcp"]
all_packages = ["hanzo", "hanzo-mcp", "hanzo-agents", "hanzo-memory", "hanzo-network"]
all_packages = [
"hanzo",
"hanzo-mcp",
"hanzo-agents",
"hanzo-memory",
"hanzo-network",
]
if packages:
to_update = list(packages)
@@ -567,7 +603,9 @@ def update(ctx, upgrade_all: bool, force: bool, packages: tuple):
if result.returncode == 0:
# Get new version
ver_result = subprocess.run(["uv", "tool", "list"], capture_output=True, text=True, timeout=10)
ver_result = subprocess.run(
["uv", "tool", "list"], capture_output=True, text=True, timeout=10
)
version = "?"
for line in ver_result.stdout.split("\n"):
if line.startswith(pkg + " "):
@@ -580,7 +618,10 @@ def update(ctx, upgrade_all: bool, force: bool, packages: tuple):
# Try install if upgrade failed (package not installed)
if "not installed" in result.stderr.lower():
install_result = subprocess.run(
["uv", "tool", "install", pkg], capture_output=True, text=True, timeout=120
["uv", "tool", "install", pkg],
capture_output=True,
text=True,
timeout=120,
)
if install_result.returncode == 0:
console.print("[green]✓[/green] installed")
+5 -1
View File
@@ -101,7 +101,11 @@ async def run(ctx, agents: tuple, task: str, parallel: bool, timeout: Optional[i
network = create_network(agents=agent_list)
# Run task
result = await asyncio.wait_for(network.run(task), timeout=timeout) if timeout else await network.run(task)
result = (
await asyncio.wait_for(network.run(task), timeout=timeout)
if timeout
else await network.run(task)
)
console.print("[green]Task completed![/green]")
console.print(result)
+90 -21
View File
@@ -64,7 +64,11 @@ class AuthManager:
if os.getenv("HANZO_API_KEY"):
return os.getenv("HANZO_API_KEY")
auth = self.load_auth()
return auth.get("api_key") or auth.get("token") or auth.get("tokens", {}).get("access_token")
return (
auth.get("api_key")
or auth.get("token")
or auth.get("tokens", {}).get("access_token")
)
@click.group(name="auth")
@@ -133,7 +137,9 @@ def login(ctx, api_key: str, device_code: bool, headless: bool):
def _get_iam_url(auth_mgr: AuthManager) -> str:
"""Get the IAM URL from env or stored config."""
existing = auth_mgr.load_auth()
return os.getenv("IAM_URL", os.getenv("HANZO_IAM_URL", existing.get("iam_url", HANZO_IAM_URL)))
return os.getenv(
"IAM_URL", os.getenv("HANZO_IAM_URL", existing.get("iam_url", HANZO_IAM_URL))
)
def _decode_jwt_claims(token: str) -> dict:
@@ -327,7 +333,9 @@ def _login_device_code(auth_mgr: AuthManager, headless: bool):
device_code = data["device_code"]
user_code = data["user_code"]
verification_url = data.get("verification_uri", f"{iam_url}/device")
verification_url_complete = data.get("verification_uri_complete", f"{verification_url}?user_code={user_code}")
verification_url_complete = data.get(
"verification_uri_complete", f"{verification_url}?user_code={user_code}"
)
expires_in = data.get("expires_in", 300)
interval = data.get("interval", 5)
@@ -401,7 +409,9 @@ def _login_device_code(auth_mgr: AuthManager, headless: bool):
interval += 5
continue
elif error == "expired_token":
console.print("[red]Device code expired. Please try again.[/red]")
console.print(
"[red]Device code expired. Please try again.[/red]"
)
return
elif error == "access_denied":
console.print("[red]Authentication denied.[/red]")
@@ -430,7 +440,13 @@ def logout(ctx):
# Clear login state but preserve IAM config
auth = auth_mgr.load_auth()
preserved = {}
for key in ("iam_url", "iam_client_id", "iam_client_secret", "iam_org", "iam_app"):
for key in (
"iam_url",
"iam_client_id",
"iam_client_secret",
"iam_org",
"iam_app",
):
if key in auth:
preserved[key] = auth[key]
auth_mgr.save_auth(preserved)
@@ -529,7 +545,9 @@ def whoami():
lines.append(f"[cyan]Last Login:[/cyan] {auth['last_login']}")
content = "\n".join(lines) if lines else "[dim]No user information available[/dim]"
console.print(Panel(content, title="[bold cyan]User Information[/bold cyan]", box=box.ROUNDED))
console.print(
Panel(content, title="[bold cyan]User Information[/bold cyan]", box=box.ROUNDED)
)
@auth_group.command(name="set-key")
@@ -581,14 +599,23 @@ def _print_context():
ctx = load_context()
if not ctx:
console.print("[yellow]No context set.[/yellow]")
console.print("[dim]Run 'hanzo auth context set --org ORG --project PROJECT --env ENV'[/dim]")
console.print(
"[dim]Run 'hanzo auth context set --org ORG --project PROJECT --env ENV'[/dim]"
)
return
table = Table(title="Active Context", box=box.ROUNDED)
table.add_column("Property", style="cyan")
table.add_column("Value", style="white")
for key in ("org_id", "org_name", "project_id", "project_name", "env_id", "env_name"):
for key in (
"org_id",
"org_name",
"project_id",
"project_name",
"env_id",
"env_name",
):
if ctx.get(key):
label = key.replace("_", " ").title()
table.add_row(label, ctx[key])
@@ -608,7 +635,13 @@ def context_set(org: str, project: str, env: str):
hanzo auth context set --org hanzo
hanzo auth context set --org hanzo --project myapp --env development
"""
from ..utils.api_client import PaaSClient, env_url, org_url, project_url, save_context
from ..utils.api_client import (
PaaSClient,
env_url,
org_url,
project_url,
save_context,
)
try:
client = PaaSClient()
@@ -636,7 +669,9 @@ def context_set(org: str, project: str, env: str):
console.print(f"[red]Organization '{org}' not found.[/red]")
return
ctx["org_id"] = matched_org.get("_id") or matched_org.get("iid") or matched_org.get("id")
ctx["org_id"] = (
matched_org.get("_id") or matched_org.get("iid") or matched_org.get("id")
)
ctx["org_name"] = matched_org.get("name", org)
console.print(f"[green]✓[/green] Organization: {ctx['org_name']}")
@@ -648,7 +683,11 @@ def context_set(org: str, project: str, env: str):
save_context(ctx)
return
projects = data if isinstance(data, list) else data.get("projects", data.get("data", []))
projects = (
data
if isinstance(data, list)
else data.get("projects", data.get("data", []))
)
matched_proj = None
for p in projects:
pid = p.get("_id") or p.get("iid") or p.get("id", "")
@@ -658,11 +697,15 @@ def context_set(org: str, project: str, env: str):
break
if not matched_proj:
console.print(f"[yellow]Project '{project}' not found. Saving org-only context.[/yellow]")
console.print(
f"[yellow]Project '{project}' not found. Saving org-only context.[/yellow]"
)
save_context(ctx)
return
ctx["project_id"] = matched_proj.get("_id") or matched_proj.get("iid") or matched_proj.get("id")
ctx["project_id"] = (
matched_proj.get("_id") or matched_proj.get("iid") or matched_proj.get("id")
)
ctx["project_name"] = matched_proj.get("name", project)
console.print(f"[green]✓[/green] Project: {ctx['project_name']}")
@@ -674,7 +717,11 @@ def context_set(org: str, project: str, env: str):
save_context(ctx)
return
envs = data if isinstance(data, list) else data.get("environments", data.get("data", []))
envs = (
data
if isinstance(data, list)
else data.get("environments", data.get("data", []))
)
matched_env = None
for e in envs:
eid = e.get("_id") or e.get("iid") or e.get("id", "")
@@ -684,11 +731,15 @@ def context_set(org: str, project: str, env: str):
break
if not matched_env:
console.print(f"[yellow]Environment '{env}' not found. Saving org+project context.[/yellow]")
console.print(
f"[yellow]Environment '{env}' not found. Saving org+project context.[/yellow]"
)
save_context(ctx)
return
ctx["env_id"] = matched_env.get("_id") or matched_env.get("iid") or matched_env.get("id")
ctx["env_id"] = (
matched_env.get("_id") or matched_env.get("iid") or matched_env.get("id")
)
ctx["env_name"] = matched_env.get("name", env)
console.print(f"[green]✓[/green] Environment: {ctx['env_name']}")
@@ -699,7 +750,13 @@ def context_set(org: str, project: str, env: str):
@context_group.command(name="list")
def context_list():
"""List available orgs, projects, and environments."""
from ..utils.api_client import PaaSClient, env_url, org_url, project_url, load_context
from ..utils.api_client import (
PaaSClient,
env_url,
org_url,
project_url,
load_context,
)
try:
client = PaaSClient()
@@ -735,9 +792,16 @@ def context_list():
data = client.get(project_url(current["org_id"]))
if data:
projects = data if isinstance(data, list) else data.get("projects", data.get("data", []))
projects = (
data
if isinstance(data, list)
else data.get("projects", data.get("data", []))
)
if projects:
ptable = Table(title=f"Projects in {current.get('org_name', current['org_id'])}", box=box.ROUNDED)
ptable = Table(
title=f"Projects in {current.get('org_name', current['org_id'])}",
box=box.ROUNDED,
)
ptable.add_column("", style="green", width=2)
ptable.add_column("ID", style="cyan")
ptable.add_column("Name", style="white")
@@ -756,10 +820,15 @@ def context_list():
data = client.get(env_url(current["org_id"], current["project_id"]))
if data:
envs = data if isinstance(data, list) else data.get("environments", data.get("data", []))
envs = (
data
if isinstance(data, list)
else data.get("environments", data.get("data", []))
)
if envs:
etable = Table(
title=f"Environments in {current.get('project_name', current['project_id'])}", box=box.ROUNDED
title=f"Environments in {current.get('project_name', current['project_id'])}",
box=box.ROUNDED,
)
etable.add_column("", style="green", width=2)
etable.add_column("ID", style="cyan")
+31 -8
View File
@@ -66,7 +66,9 @@ def flows():
@flows.command(name="list")
@click.option("--status", type=click.Choice(["active", "inactive", "all"]), default="all")
@click.option(
"--status", type=click.Choice(["active", "inactive", "all"]), default="all"
)
def flows_list(status: str):
"""List all automation flows."""
params = {}
@@ -97,12 +99,19 @@ def flows_list(status: str):
console.print(table)
if not items:
console.print("[dim]No flows found. Create one with 'hanzo auto flows create'[/dim]")
console.print(
"[dim]No flows found. Create one with 'hanzo auto flows create'[/dim]"
)
@flows.command(name="create")
@click.option("--name", "-n", prompt=True, help="Flow name")
@click.option("--trigger", "-t", type=click.Choice(["webhook", "schedule", "manual"]), default="manual")
@click.option(
"--trigger",
"-t",
type=click.Choice(["webhook", "schedule", "manual"]),
default="manual",
)
def flows_create(name: str, trigger: str):
"""Create a new automation flow."""
body = {"name": name, "trigger_type": trigger}
@@ -272,7 +281,9 @@ def connections_list():
console.print(table)
if not items:
console.print("[dim]No connections found. Add one with 'hanzo auto connections add'[/dim]")
console.print(
"[dim]No connections found. Add one with 'hanzo auto connections add'[/dim]"
)
@connections.command(name="add")
@@ -330,7 +341,9 @@ def init():
@click.option("--port", "-p", default=8080, help="Port to run on")
def dev(port: int):
"""Start local development server."""
console.print(f"[cyan]Starting Hanzo Auto development server on port {port}...[/cyan]")
console.print(
f"[cyan]Starting Hanzo Auto development server on port {port}...[/cyan]"
)
console.print()
console.print(f" [cyan]Visual Editor:[/cyan] http://localhost:{port}")
console.print(f" [cyan]API:[/cyan] http://localhost:{port}/api")
@@ -372,7 +385,11 @@ def runs():
@runs.command(name="list")
@click.option("--flow", "-f", help="Filter by flow")
@click.option("--status", type=click.Choice(["success", "failed", "running", "all"]), default="all")
@click.option(
"--status",
type=click.Choice(["success", "failed", "running", "all"]),
default="all",
)
@click.option("--limit", "-n", default=50, help="Max results")
def runs_list(flow: str, status: str, limit: int):
"""List flow runs."""
@@ -423,7 +440,11 @@ def runs_show(run_id: str):
data = check_response(resp)
run_status = data.get("status", "unknown")
status_style = "green" if run_status in ("success", "completed") else "red" if run_status == "failed" else "cyan"
status_style = (
"green"
if run_status in ("success", "completed")
else "red" if run_status == "failed" else "cyan"
)
console.print(
Panel(
@@ -461,7 +482,9 @@ def runs_logs(run_id: str, step: str):
ts = str(line.get("timestamp", ""))[:19]
level = line.get("level", "info")
msg = line.get("message", "")
style = "red" if level == "error" else "yellow" if level == "warn" else "dim"
style = (
"red" if level == "error" else "yellow" if level == "warn" else "dim"
)
console.print(f"[dim]{ts}[/dim] [{style}]{level}[/{style}] {msg}")
else:
console.print(str(line))
+41 -16
View File
@@ -91,7 +91,9 @@ def api_request(method: str, path: str, **kwargs) -> httpx.Response:
headers["Authorization"] = f"Bearer {api_key}"
with httpx.Client(timeout=60) as client:
return getattr(client, method)(f"{HANZO_API_URL}{path}", headers=headers, **kwargs)
return getattr(client, method)(
f"{HANZO_API_URL}{path}", headers=headers, **kwargs
)
def service_request(base_url: str, method: str, path: str, **kwargs) -> httpx.Response:
@@ -109,7 +111,9 @@ def service_request(base_url: str, method: str, path: str, **kwargs) -> httpx.Re
try:
with httpx.Client(timeout=60) as client:
return getattr(client, method)(f"{base_url}{path}", headers=headers, **kwargs)
return getattr(client, method)(
f"{base_url}{path}", headers=headers, **kwargs
)
except httpx.ConnectError:
raise click.ClickException(f"Could not connect to {base_url}")
@@ -210,7 +214,7 @@ def init(name: str, org: Optional[str], region: str):
(project_dir / "seed").mkdir(exist_ok=True)
# Create config.toml
config = f'''# Hanzo Base Configuration
config = f"""# Hanzo Base Configuration
# https://docs.hanzo.ai/base/config
[project]
@@ -253,7 +257,7 @@ enabled = true
[commerce]
enabled = false
'''
"""
(project_dir / "config.toml").write_text(config)
# Create seed.sql
@@ -301,7 +305,8 @@ CREATE POLICY "Users can update own profile"
# Create example function
func_dir = project_dir / "functions" / "hello"
func_dir.mkdir(exist_ok=True)
(func_dir / "index.ts").write_text("""import { serve } from "https://deno.land/[email protected]/http/server.ts"
(func_dir / "index.ts").write_text(
"""import { serve } from "https://deno.land/[email protected]/http/server.ts"
serve(async (req) => {
const { name } = await req.json()
@@ -314,7 +319,8 @@ serve(async (req) => {
{ headers: { "Content-Type": "application/json" } },
)
})
""")
"""
)
# Create .gitignore
gitignore = Path.cwd() / ".gitignore"
@@ -351,7 +357,10 @@ def link(project: Optional[str]):
if project:
# Find by ID or name
matched = next((p for p in projects if p["id"] == project or p["name"] == project), None)
matched = next(
(p for p in projects if p["id"] == project or p["name"] == project),
None,
)
if not matched:
raise click.ClickException(f"Project '{project}' not found")
selected = matched
@@ -380,7 +389,9 @@ def start():
try:
subprocess.run(["docker", "info"], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
raise click.ClickException("Docker is required. Install from https://docker.com")
raise click.ClickException(
"Docker is required. Install from https://docker.com"
)
# Start services via docker compose
compose_file = Path.cwd() / "hanzo" / "docker-compose.yml"
@@ -481,7 +492,9 @@ volumes:
console.print()
console.print(" [cyan]Studio:[/cyan] http://localhost:54323")
console.print(" [cyan]API:[/cyan] http://localhost:54321")
console.print(" [cyan]Database:[/cyan] postgresql://postgres:postgres@localhost:54322/postgres")
console.print(
" [cyan]Database:[/cyan] postgresql://postgres:postgres@localhost:54322/postgres"
)
console.print(" [cyan]Realtime:[/cyan] ws://localhost:54325")
@@ -513,14 +526,18 @@ def status():
]
for name, local, remote in services:
table.add_row(name, f"● {local}", f"● {remote}" if project_id else "○ Not linked")
table.add_row(
name, f"● {local}", f"● {remote}" if project_id else "○ Not linked"
)
console.print(table)
if project_id:
console.print(f"\n[dim]Linked to project: {project_id}[/dim]")
else:
console.print("\n[dim]Run 'hanzo base link' to connect to a remote project[/dim]")
console.print(
"\n[dim]Run 'hanzo base link' to connect to a remote project[/dim]"
)
# ============================================================================
@@ -694,7 +711,9 @@ def users_list(limit: int):
raise click.ClickException("No project linked")
try:
resp = api_request("get", f"/v1/base/{project_id}/auth/users", params={"limit": limit})
resp = api_request(
"get", f"/v1/base/{project_id}/auth/users", params={"limit": limit}
)
users = resp.json().get("users", [])
table = Table(title="Users", box=box.ROUNDED)
@@ -949,7 +968,8 @@ def functions_new(name: str):
func_dir = Path.cwd() / "hanzo" / "functions" / name
func_dir.mkdir(parents=True, exist_ok=True)
(func_dir / "index.ts").write_text(f"""import {{ serve }} from "https://deno.land/[email protected]/http/server.ts"
(func_dir / "index.ts").write_text(
f"""import {{ serve }} from "https://deno.land/[email protected]/http/server.ts"
serve(async (req) => {{
const data = {{
@@ -961,7 +981,8 @@ serve(async (req) => {{
{{ headers: {{ "Content-Type": "application/json" }} }},
)
}})
""")
"""
)
console.print(f"[green]✓ Created function: {name}[/green]")
console.print(f" Edit: hanzo/functions/{name}/index.ts")
@@ -1094,7 +1115,9 @@ def projects_create(name: str, org: Optional[str], region: str):
@click.argument("project_id")
def projects_delete(project_id: str):
"""Delete a project."""
if not Confirm.ask(f"[red]Delete project {project_id}? This cannot be undone.[/red]"):
if not Confirm.ask(
f"[red]Delete project {project_id}? This cannot be undone.[/red]"
):
return
console.print("[green]✓ Project deleted[/green]")
@@ -1135,7 +1158,9 @@ def gen():
@gen.command(name="types")
@click.option("--lang", type=click.Choice(["typescript", "python", "go"]), default="typescript")
@click.option(
"--lang", type=click.Choice(["typescript", "python", "go"]), default="typescript"
)
@click.option("--output", "-o", default="types", help="Output directory")
def gen_types(lang: str, output: str):
"""Generate types from database schema."""
+9 -3
View File
@@ -18,7 +18,9 @@ from ..utils.output import console
@click.option("--local/--cloud", default=True, help="Use local or cloud model")
@click.option("--once", is_flag=True, help="Single question mode")
@click.option("--system", "-s", help="System prompt")
@click.option("--repl", is_flag=True, help="Start full REPL interface (like Claude Code)")
@click.option(
"--repl", is_flag=True, help="Start full REPL interface (like Claude Code)"
)
@click.option("--ipython", is_flag=True, help="Use IPython REPL interface")
@click.option("--tui", is_flag=True, help="Use beautiful TUI interface")
@click.argument("prompt", nargs=-1)
@@ -78,7 +80,9 @@ def chat_command(
asyncio.run(interactive_chat(ctx, model, local, system))
async def ask_once(ctx, prompt: str, model: str, local: bool, system: Optional[str] = None):
async def ask_once(
ctx, prompt: str, model: str, local: bool, system: Optional[str] = None
):
"""Ask a single question."""
messages = []
if system:
@@ -168,7 +172,9 @@ async def interactive_chat(ctx, model: str, local: bool, system: Optional[str]):
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
console.print(f"[cyan]Chat session started[/cyan] (model: {model}, mode: {'local' if local else 'cloud'})")
console.print(
f"[cyan]Chat session started[/cyan] (model: {model}, mode: {'local' if local else 'cloud'})"
)
console.print("Type 'exit' or Ctrl+D to quit\n")
session = PromptSession(history=FileHistory(".hanzo_chat_history"))
+26 -8
View File
@@ -397,7 +397,9 @@ def operations_wait(operation_id: str, timeout: int):
console.print("[green]✓ Operation completed[/green]")
return
elif status in ("failed", "error"):
console.print(f"[red]✗ Operation failed: {data.get('error', 'Unknown error')}[/red]")
console.print(
f"[red]✗ Operation failed: {data.get('error', 'Unknown error')}[/red]"
)
return
time.sleep(2)
@@ -426,7 +428,9 @@ def create_service_group(service_key: str, service_info: dict):
# create command
@service_group.command(name="create")
@click.option("--name", default="default", help="Instance name")
@click.option("--tier", type=click.Choice(["free", "pro", "enterprise"]), default="free")
@click.option(
"--tier", type=click.Choice(["free", "pro", "enterprise"]), default="free"
)
@click.option("--region", default="us-west-2", help="Deployment region")
def create(name: str, tier: str, region: str):
"""Create a new instance."""
@@ -463,7 +467,11 @@ def create_service_group(service_key: str, service_info: dict):
# env command
@service_group.command(name="env")
@click.option("--name", default="default", help="Instance name")
@click.option("--shell", type=click.Choice(["bash", "zsh", "fish", "powershell"]), default="bash")
@click.option(
"--shell",
type=click.Choice(["bash", "zsh", "fish", "powershell"]),
default="bash",
)
@click.option("--export", "do_export", is_flag=True, help="Print export statements")
def env(name: str, shell: str, do_export: bool):
"""Show/export environment variables."""
@@ -574,7 +582,9 @@ def _create_instance(service: str, name: str, tier: str, region: str):
)
if resp.status_code == 401:
console.print("[red]Authentication failed. Run 'hanzo auth login'.[/red]")
console.print(
"[red]Authentication failed. Run 'hanzo auth login'.[/red]"
)
return
if resp.status_code == 402:
@@ -583,7 +593,9 @@ def _create_instance(service: str, name: str, tier: str, region: str):
return
if resp.status_code == 409:
console.print(f"[yellow]Instance '{name}' already exists. Use 'update' to modify.[/yellow]")
console.print(
f"[yellow]Instance '{name}' already exists. Use 'update' to modify.[/yellow]"
)
return
if resp.status_code >= 400:
@@ -611,7 +623,9 @@ def _create_instance(service: str, name: str, tier: str, region: str):
console.print()
console.print(f"[cyan]URL:[/cyan] {data.get('url')}")
console.print()
console.print(f"[dim]Run 'hanzo cloud {service} env --export' for environment variables[/dim]")
console.print(
f"[dim]Run 'hanzo cloud {service} env --export' for environment variables[/dim]"
)
except httpx.ConnectError:
console.print("[red]Could not connect to Hanzo API.[/red]")
@@ -690,7 +704,9 @@ def _delete_instance(service: str, name: str, force: bool):
info = SERVICES[service]
if not force:
if not Confirm.ask(f"[red]Delete {info['name']} '{name}'? This cannot be undone.[/red]"):
if not Confirm.ask(
f"[red]Delete {info['name']} '{name}'? This cannot be undone.[/red]"
):
console.print("Cancelled.")
return
@@ -797,7 +813,9 @@ def _env_instance(service: str, name: str, shell: str, do_export: bool):
console.print(table)
console.print()
console.print(f"[dim]Run 'hanzo cloud {service} env --export' for export statements[/dim]")
console.print(
f"[dim]Run 'hanzo cloud {service} env --export' for export statements[/dim]"
)
def _status_instance(service: str, name: str):
+3 -1
View File
@@ -115,7 +115,9 @@ def set(ctx, key: str, value: str, is_global: bool, is_local: bool):
# Save config
save_config(config_path, config)
console.print(f"[green]✓[/green] Set {key} = {parsed_value} in {config_name} config")
console.print(
f"[green]✓[/green] Set {key} = {parsed_value} in {config_name} config"
)
@config_group.command()
+54 -12
View File
@@ -58,8 +58,14 @@ def inbox():
@inbox.command(name="list")
@click.option("--status", type=click.Choice(["open", "pending", "resolved", "all"]), default="open")
@click.option("--channel", type=click.Choice(["email", "chat", "social", "all"]), default="all")
@click.option(
"--status",
type=click.Choice(["open", "pending", "resolved", "all"]),
default="open",
)
@click.option(
"--channel", type=click.Choice(["email", "chat", "social", "all"]), default="all"
)
@click.option("--limit", "-n", default=50, help="Max results")
def inbox_list(status: str, channel: str, limit: int):
"""List inbox conversations."""
@@ -81,7 +87,9 @@ def inbox_list(status: str, channel: str, limit: int):
for c in data.get("conversations", []):
st = c.get("status", "open")
st_style = {"open": "yellow", "pending": "cyan", "resolved": "green"}.get(st, "white")
st_style = {"open": "yellow", "pending": "cyan", "resolved": "green"}.get(
st, "white"
)
table.add_row(
c.get("id", "")[:12],
c.get("subject", ""),
@@ -118,7 +126,9 @@ def inbox_show(conversation_id: str):
for msg in messages:
sender = msg.get("sender", "unknown")
style = "cyan" if msg.get("is_agent") else "white"
console.print(f"[{style}]{sender}[/{style}] [dim]{msg.get('created_at', '')}[/dim]")
console.print(
f"[{style}]{sender}[/{style}] [dim]{msg.get('created_at', '')}[/dim]"
)
console.print(f" {msg.get('body', '')}")
console.print()
@@ -128,7 +138,11 @@ def inbox_show(conversation_id: str):
@click.option("--agent", "-a", required=True, help="Agent email or ID")
def inbox_assign(conversation_id: str, agent: str):
"""Assign conversation to agent."""
resp = _request("post", f"/v1/inbox/conversations/{conversation_id}/assign", json={"agent": agent})
resp = _request(
"post",
f"/v1/inbox/conversations/{conversation_id}/assign",
json={"agent": agent},
)
check_response(resp)
console.print(f"[green]✓[/green] Conversation assigned to {agent}")
@@ -138,7 +152,11 @@ def inbox_assign(conversation_id: str, agent: str):
@click.option("--message", "-m", prompt=True, help="Reply message")
def inbox_reply(conversation_id: str, message: str):
"""Reply to a conversation."""
resp = _request("post", f"/v1/inbox/conversations/{conversation_id}/reply", json={"body": message})
resp = _request(
"post",
f"/v1/inbox/conversations/{conversation_id}/reply",
json={"body": message},
)
check_response(resp)
console.print(f"[green]✓[/green] Reply sent")
@@ -339,7 +357,12 @@ def deals_show(deal_id: str):
@click.option("--stage", "-s", help="Initial stage")
def deals_create(name: str, value: float, contact: str, pipeline: str, stage: str):
"""Create a deal."""
payload: dict = {"name": name, "value": value, "contact_id": contact, "pipeline": pipeline}
payload: dict = {
"name": name,
"value": value,
"contact_id": contact,
"pipeline": pipeline,
}
if stage:
payload["stage"] = stage
resp = _request("post", "/v1/deals", json=payload)
@@ -433,7 +456,11 @@ def invoices():
@invoices.command(name="list")
@click.option("--status", type=click.Choice(["draft", "sent", "paid", "overdue", "all"]), default="all")
@click.option(
"--status",
type=click.Choice(["draft", "sent", "paid", "overdue", "all"]),
default="all",
)
@click.option("--limit", "-n", default=50, help="Max results")
def invoices_list(status: str, limit: int):
"""List invoices."""
@@ -478,7 +505,9 @@ def invoices_create(customer: str, amount: float, due: str, items: str):
payload["items"] = json.loads(items)
resp = _request("post", "/v1/invoices", json=payload)
data = check_response(resp)
console.print(f"[green]✓[/green] Invoice {data.get('number', '')} created for ${amount:,.2f}")
console.print(
f"[green]✓[/green] Invoice {data.get('number', '')} created for ${amount:,.2f}"
)
@invoices.command(name="send")
@@ -524,7 +553,11 @@ def orders():
@orders.command(name="list")
@click.option("--status", type=click.Choice(["pending", "processing", "shipped", "delivered", "all"]), default="all")
@click.option(
"--status",
type=click.Choice(["pending", "processing", "shipped", "delivered", "all"]),
default="all",
)
@click.option("--limit", "-n", default=50, help="Max results")
def orders_list(status: str, limit: int):
"""List orders."""
@@ -543,7 +576,11 @@ def orders_list(status: str, limit: int):
for o in data.get("orders", []):
st = o.get("status", "pending")
st_style = {"delivered": "green", "shipped": "cyan", "processing": "yellow"}.get(st, "white")
st_style = {
"delivered": "green",
"shipped": "cyan",
"processing": "yellow",
}.get(st, "white")
table.add_row(
o.get("order_number", ""),
o.get("customer_name", ""),
@@ -592,7 +629,12 @@ def orders_show(order_id: str):
@orders.command(name="update-status")
@click.argument("order_id")
@click.option("--status", "-s", type=click.Choice(["processing", "shipped", "delivered"]), required=True)
@click.option(
"--status",
"-s",
type=click.Choice(["processing", "shipped", "delivered"]),
required=True,
)
@click.option("--tracking", "-t", help="Tracking number")
def orders_update_status(order_id: str, status: str, tracking: str):
"""Update order status."""
+79 -19
View File
@@ -62,7 +62,12 @@ def doc_group():
@doc_group.command(name="create")
@click.argument("name")
@click.option("--region", "-r", multiple=True, help="Regions for replication")
@click.option("--tier", "-t", type=click.Choice(["free", "standard", "dedicated"]), default="standard")
@click.option(
"--tier",
"-t",
type=click.Choice(["free", "standard", "dedicated"]),
default="standard",
)
def doc_create(name: str, region: tuple, tier: str):
"""Create a document database."""
payload = {"name": name, "tier": tier}
@@ -77,7 +82,9 @@ def doc_create(name: str, region: tuple, tier: str):
console.print(f" Tier: {tier}")
if region:
console.print(f" Regions: {', '.join(region)}")
console.print(f" Connection: {data.get('connection_string', f'mongodb://doc.hanzo.ai/{name}')}")
console.print(
f" Connection: {data.get('connection_string', f'mongodb://doc.hanzo.ai/{name}')}"
)
@doc_group.command(name="list")
@@ -107,7 +114,9 @@ def doc_list():
console.print(table)
if not dbs:
console.print("[dim]No databases found. Create one with 'hanzo doc create'[/dim]")
console.print(
"[dim]No databases found. Create one with 'hanzo doc create'[/dim]"
)
@doc_group.command(name="describe")
@@ -158,7 +167,9 @@ def doc_connect(name: str):
"""Get connection string."""
resp = _request("get", f"/v1/databases/{name}")
data = check_response(resp)
conn = data.get("connection_string", f"mongodb://doc.hanzo.ai/{name}?authSource=admin")
conn = data.get(
"connection_string", f"mongodb://doc.hanzo.ai/{name}?authSource=admin"
)
console.print(f"[cyan]Connection string for '{name}':[/cyan]")
console.print(conn)
@@ -277,7 +288,15 @@ def collections_stats(name: str, db: str):
@click.option("--sort", "-s", help="Sort order")
@click.option("--limit", "-n", default=20, help="Max results")
@click.option("--skip", type=int, help="Skip documents")
def doc_find(collection: str, db: str, query: str, projection: str, sort: str, limit: int, skip: int):
def doc_find(
collection: str,
db: str,
query: str,
projection: str,
sort: str,
limit: int,
skip: int,
):
"""Query documents in a collection.
\b
@@ -294,7 +313,9 @@ def doc_find(collection: str, db: str, query: str, projection: str, sort: str, l
if skip:
payload["skip"] = skip
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/find", json=payload)
resp = _request(
"post", f"/v1/databases/{db}/collections/{collection}/find", json=payload
)
data = check_response(resp)
docs = data.get("documents", data.get("items", []))
@@ -325,7 +346,11 @@ def doc_insert(collection: str, db: str, doc: str, file: str):
else:
raise click.ClickException("Provide --doc or --file")
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/insert", json={"document": document})
resp = _request(
"post",
f"/v1/databases/{db}/collections/{collection}/insert",
json={"document": document},
)
data = check_response(resp)
console.print(f"[green]✓[/green] Inserted document into '{collection}'")
@@ -339,7 +364,14 @@ def doc_insert(collection: str, db: str, doc: str, file: str):
@click.option("--set", "set_fields", multiple=True, help="Fields to set")
@click.option("--unset", "unset_fields", multiple=True, help="Fields to unset")
@click.option("--upsert", is_flag=True, help="Insert if not found")
def doc_update(collection: str, db: str, filter: str, set_fields: tuple, unset_fields: tuple, upsert: bool):
def doc_update(
collection: str,
db: str,
filter: str,
set_fields: tuple,
unset_fields: tuple,
upsert: bool,
):
"""Update documents.
\b
@@ -363,11 +395,15 @@ def doc_update(collection: str, db: str, filter: str, set_fields: tuple, unset_f
"upsert": upsert,
}
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/update", json=payload)
resp = _request(
"post", f"/v1/databases/{db}/collections/{collection}/update", json=payload
)
data = check_response(resp)
console.print(f"[green]✓[/green] Updated documents in '{collection}'")
console.print(f" Matched: {data.get('matched_count', 0)}, Modified: {data.get('modified_count', 0)}")
console.print(
f" Matched: {data.get('matched_count', 0)}, Modified: {data.get('modified_count', 0)}"
)
if upsert and data.get("upserted_id"):
console.print(f" Upserted: {data['upserted_id']}")
@@ -386,7 +422,9 @@ def doc_delete_docs(collection: str, db: str, filter: str, force: bool):
return
payload = {"filter": json.loads(filter)}
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/delete", json=payload)
resp = _request(
"post", f"/v1/databases/{db}/collections/{collection}/delete", json=payload
)
data = check_response(resp)
console.print(f"[green]✓[/green] Deleted documents from '{collection}'")
@@ -400,7 +438,9 @@ def doc_delete_docs(collection: str, db: str, filter: str, force: bool):
def doc_count(collection: str, db: str, query: str):
"""Count documents."""
payload = {"filter": json.loads(query)}
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/count", json=payload)
resp = _request(
"post", f"/v1/databases/{db}/collections/{collection}/count", json=payload
)
data = check_response(resp)
console.print(f"[cyan]Count in {db}.{collection}:[/cyan] {data.get('count', 0):,}")
@@ -433,7 +473,11 @@ def indexes_list(collection: str, db: str):
for idx in items:
keys = idx.get("keys", idx.get("key", {}))
keys_str = ", ".join(f"{k}: {v}" for k, v in keys.items()) if isinstance(keys, dict) else str(keys)
keys_str = (
", ".join(f"{k}: {v}" for k, v in keys.items())
if isinstance(keys, dict)
else str(keys)
)
table.add_row(
idx.get("name", "-"),
keys_str,
@@ -447,12 +491,16 @@ def indexes_list(collection: str, db: str):
@indexes.command(name="create")
@click.argument("collection")
@click.option("--db", "-d", default="default")
@click.option("--keys", "-k", required=True, help="Index keys (e.g., 'field:1' or 'field:-1')")
@click.option(
"--keys", "-k", required=True, help="Index keys (e.g., 'field:1' or 'field:-1')"
)
@click.option("--name", "-n", help="Index name")
@click.option("--unique", "-u", is_flag=True, help="Unique index")
@click.option("--sparse", is_flag=True, help="Sparse index")
@click.option("--ttl", help="TTL in seconds")
def indexes_create(collection: str, db: str, keys: str, name: str, unique: bool, sparse: bool, ttl: str):
def indexes_create(
collection: str, db: str, keys: str, name: str, unique: bool, sparse: bool, ttl: str
):
"""Create an index.
\b
@@ -476,7 +524,9 @@ def indexes_create(collection: str, db: str, keys: str, name: str, unique: bool,
if ttl:
payload["expire_after_seconds"] = int(ttl)
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/indexes", json=payload)
resp = _request(
"post", f"/v1/databases/{db}/collections/{collection}/indexes", json=payload
)
data = check_response(resp)
console.print(f"[green]✓[/green] Index created on '{collection}'")
@@ -492,7 +542,9 @@ def indexes_create(collection: str, db: str, keys: str, name: str, unique: bool,
@click.option("--db", "-d", default="default")
def indexes_drop(collection: str, index_name: str, db: str):
"""Drop an index."""
resp = _request("delete", f"/v1/databases/{db}/collections/{collection}/indexes/{index_name}")
resp = _request(
"delete", f"/v1/databases/{db}/collections/{collection}/indexes/{index_name}"
)
check_response(resp)
console.print(f"[green]✓[/green] Index '{index_name}' dropped from '{collection}'")
@@ -549,7 +601,11 @@ def doc_users(action: str, db: str, username: str, role: str):
table.add_column("Created", style="dim")
for u in users:
table.add_row(u.get("username", ""), u.get("role", "-"), str(u.get("created_at", ""))[:19])
table.add_row(
u.get("username", ""),
u.get("role", "-"),
str(u.get("created_at", ""))[:19],
)
console.print(table)
if not users:
@@ -558,7 +614,11 @@ def doc_users(action: str, db: str, username: str, role: str):
elif action == "create":
if not username or not role:
raise click.ClickException("--username and --role required for create")
resp = _request("post", f"/v1/databases/{db}/users", json={"username": username, "role": role})
resp = _request(
"post",
f"/v1/databases/{db}/users",
json={"username": username, "role": role},
)
data = check_response(resp)
console.print(f"[green]✓[/green] User '{username}' created with role '{role}'")
if data.get("password"):
+12 -4
View File
@@ -50,9 +50,15 @@ def env_list(project: str):
table.add_column("Variables", style="yellow")
table.add_column("Updated", style="dim")
table.add_row("development", "[green]●[/green] active", "dev.app.hanzo.ai", "12", "2024-01-15")
table.add_row("staging", "[yellow]●[/yellow] idle", "staging.app.hanzo.ai", "12", "2024-01-14")
table.add_row("production", "[green]●[/green] active", "app.hanzo.ai", "15", "2024-01-13")
table.add_row(
"development", "[green]●[/green] active", "dev.app.hanzo.ai", "12", "2024-01-15"
)
table.add_row(
"staging", "[yellow]●[/yellow] idle", "staging.app.hanzo.ai", "12", "2024-01-14"
)
table.add_row(
"production", "[green]●[/green] active", "app.hanzo.ai", "15", "2024-01-13"
)
console.print(table)
@@ -122,7 +128,9 @@ def env_vars(env: str, reveal: bool):
table.add_column("Value", style="white")
table.add_column("Source", style="dim")
table.add_row("DATABASE_URL", "●●●●●●●●" if not reveal else "postgres://...", "secret")
table.add_row(
"DATABASE_URL", "●●●●●●●●" if not reveal else "postgres://...", "secret"
)
table.add_row("API_KEY", "●●●●●●●●" if not reveal else "sk-...", "secret")
table.add_row("LOG_LEVEL", "debug", "config")
table.add_row("NODE_ENV", "development", "config")
+23 -7
View File
@@ -74,7 +74,9 @@ def bus():
@bus.command(name="create")
@click.argument("name")
@click.option("--backend", "-b", type=click.Choice(["kafka", "pubsub", "redis"]), default="kafka")
@click.option(
"--backend", "-b", type=click.Choice(["kafka", "pubsub", "redis"]), default="kafka"
)
@click.option("--region", "-r", multiple=True, help="Regions for replication")
def bus_create(name: str, backend: str, region: tuple):
"""Create an event bus."""
@@ -119,7 +121,9 @@ def bus_list():
console.print(table)
if not buses:
console.print("[dim]No event buses found. Create one with 'hanzo events bus create'[/dim]")
console.print(
"[dim]No event buses found. Create one with 'hanzo events bus create'[/dim]"
)
@bus.command(name="describe")
@@ -178,7 +182,9 @@ def schema():
@schema.command(name="register")
@click.argument("name")
@click.option("--file", "-f", type=click.Path(exists=True), help="Schema file")
@click.option("--format", "fmt", type=click.Choice(["json", "avro", "protobuf"]), default="json")
@click.option(
"--format", "fmt", type=click.Choice(["json", "avro", "protobuf"]), default="json"
)
@click.option("--version", "-v", help="Schema version")
def schema_register(name: str, file: str, fmt: str, version: str):
"""Register an event schema."""
@@ -391,7 +397,11 @@ def route():
@route.command(name="create")
@click.argument("name")
@click.option("--from", "from_stream", required=True, help="Source stream")
@click.option("--to", required=True, help="Target: service:<name>, task:<name>, queue:<name>, webhook:<url>")
@click.option(
"--to",
required=True,
help="Target: service:<name>, task:<name>, queue:<name>, webhook:<url>",
)
@click.option("--filter", "-f", help="Filter expression")
@click.option("--transform", "-t", help="Transform expression")
def route_create(name: str, from_stream: str, to: str, filter: str, transform: str):
@@ -539,7 +549,9 @@ def events_publish(stream: str, data: str, file: str, key: str):
@events_group.command(name="tail")
@click.argument("stream")
@click.option("--from", "from_pos", type=click.Choice(["latest", "earliest"]), default="latest")
@click.option(
"--from", "from_pos", type=click.Choice(["latest", "earliest"]), default="latest"
)
@click.option("--filter", "-f", help="Filter expression")
@click.option("--limit", "-n", type=int, help="Max events")
def events_tail(stream: str, from_pos: str, filter: str, limit: int):
@@ -569,7 +581,9 @@ def events_tail(stream: str, from_pos: str, filter: str, limit: int):
@events_group.command(name="replay")
@click.option("--stream", "-s", required=True, help="Stream to replay")
@click.option("--from", "from_pos", required=True, help="Start: earliest, timestamp, offset")
@click.option(
"--from", "from_pos", required=True, help="Start: earliest, timestamp, offset"
)
@click.option("--to", "to_pos", help="End: latest, timestamp, offset")
@click.option("--target", "-t", help="Target route or consumer")
@click.option("--dry-run", is_flag=True, help="Show what would be replayed")
@@ -587,7 +601,9 @@ def events_replay(stream: str, from_pos: str, to_pos: str, target: str, dry_run:
data = check_response(resp)
if dry_run:
console.print(f"[dim]Dry run: {data.get('event_count', 0)} events would be replayed[/dim]")
console.print(
f"[dim]Dry run: {data.get('event_count', 0)} events would be replayed[/dim]"
)
else:
console.print(f"[green]✓[/green] Replay complete")
console.print(f" Events replayed: {data.get('replayed', 0):,}")
+15 -5
View File
@@ -90,7 +90,9 @@ def flow_create(name: str, template: str, description: str):
@flow_group.command(name="list")
@click.option("--status", type=click.Choice(["deployed", "draft", "all"]), default="all")
@click.option(
"--status", type=click.Choice(["deployed", "draft", "all"]), default="all"
)
def flow_list(status: str):
"""List LLM flows."""
params = {}
@@ -175,7 +177,9 @@ def flow_run(name: str, input_data: str, stream: bool, verbose: bool):
if verbose and data.get("steps"):
for step in data["steps"]:
console.print(f" [dim]→ {step.get('name', 'step')}: {step.get('status', 'done')}[/dim]")
console.print(
f" [dim]→ {step.get('name', 'step')}: {step.get('status', 'done')}[/dim]"
)
console.print()
console.print("[green]Output:[/green]")
@@ -233,7 +237,9 @@ def flow_import(file: str, name: str):
console.print(f"[cyan]Importing flow from '{file}'...[/cyan]")
resp = _request("post", "/v1/flows/import", json=body)
data = check_response(resp)
console.print(f"[green]✓[/green] Flow '{data.get('name', name or 'imported')}' imported")
console.print(
f"[green]✓[/green] Flow '{data.get('name', name or 'imported')}' imported"
)
# ============================================================================
@@ -317,7 +323,9 @@ def custom():
@custom.command(name="create")
@click.argument("name")
@click.option("--template", "-t", type=click.Choice(["tool", "chain", "agent"]), default="tool")
@click.option(
"--template", "-t", type=click.Choice(["tool", "chain", "agent"]), default="tool"
)
def custom_create(name: str, template: str):
"""Create a custom component."""
body = {"name": name, "template": template}
@@ -540,7 +548,9 @@ def templates_list():
def templates_use(template: str, name: str):
"""Create flow from template."""
console.print(f"[cyan]Creating flow '{name}' from template '{template}'...[/cyan]")
resp = _request("post", "/v1/flows/from-template", json={"template": template, "name": name})
resp = _request(
"post", "/v1/flows/from-template", json={"template": template, "name": name}
)
data = check_response(resp)
console.print(f"[green]✓[/green] Flow created")
+33 -9
View File
@@ -66,14 +66,18 @@ def fn_group():
@click.option(
"--runtime",
"-r",
type=click.Choice(["python3.12", "python3.11", "node20", "node18", "go1.22", "rust", "deno"]),
type=click.Choice(
["python3.12", "python3.11", "node20", "node18", "go1.22", "rust", "deno"]
),
default="python3.12",
)
@click.option("--memory", "-m", default="256", help="Memory in MB (128-4096)")
@click.option("--timeout", "-t", default=30, help="Timeout in seconds (1-900)")
@click.option("--region", help="Deployment region")
@click.option("--from", "source", help="Source: directory, git URL, or template")
def fn_create(name: str, runtime: str, memory: str, timeout: int, region: str, source: str):
def fn_create(
name: str, runtime: str, memory: str, timeout: int, region: str, source: str
):
"""Create a new function.
\b
@@ -140,7 +144,9 @@ def fn_list(region: str, runtime: str):
console.print(table)
if not items:
console.print("[dim]No functions found. Create one with 'hanzo fn create'[/dim]")
console.print(
"[dim]No functions found. Create one with 'hanzo fn create'[/dim]"
)
@fn_group.command(name="describe")
@@ -234,7 +240,12 @@ def triggers():
@triggers.command(name="add")
@click.argument("function")
@click.option("--type", "trigger_type", type=click.Choice(["http", "event", "cron", "queue", "storage"]), required=True)
@click.option(
"--type",
"trigger_type",
type=click.Choice(["http", "event", "cron", "queue", "storage"]),
required=True,
)
@click.option("--path", "-p", help="HTTP path (for http triggers)")
@click.option("--method", "-m", multiple=True, help="HTTP methods (for http triggers)")
@click.option("--event", "-e", help="Event type (for event triggers)")
@@ -312,7 +323,10 @@ def triggers_list(function: str):
table.add_column("Status", style="dim")
for t in items:
source = t.get("path", t.get("event", t.get("schedule", t.get("queue", t.get("bucket", "-")))))
source = t.get(
"path",
t.get("event", t.get("schedule", t.get("queue", t.get("bucket", "-")))),
)
t_status = t.get("status", "active")
style = "green" if t_status == "active" else "yellow"
table.add_row(
@@ -383,7 +397,11 @@ def fn_invoke(name: str, data: str, payload_file: str, async_invoke: bool, tail:
console.print(f" Response: {json.dumps(result['body'], default=str)}")
if tail and result.get("request_id"):
log_resp = _request("get", f"/v1/functions/{name}/logs", params={"request_id": result["request_id"]})
log_resp = _request(
"get",
f"/v1/functions/{name}/logs",
params={"request_id": result["request_id"]},
)
log_data = check_response(log_resp)
for line in log_data.get("logs", []):
console.print(str(line))
@@ -426,7 +444,9 @@ def fn_logs(name: str, follow: bool, since: str, log_filter: str, limit: int):
ts = str(line.get("timestamp", ""))[:19]
level = line.get("level", "info")
msg = line.get("message", "")
style = "red" if level == "error" else "yellow" if level == "warn" else "dim"
style = (
"red" if level == "error" else "yellow" if level == "warn" else "dim"
)
console.print(f"[dim]{ts}[/dim] [{style}]{level}[/{style}] {msg}")
else:
console.print(str(line))
@@ -622,7 +642,9 @@ def fn_rollback(name: str, version: str):
console.print(f"[cyan]Rolling back '{name}'...[/cyan]")
resp = _request("post", f"/v1/functions/{name}/rollback", json=body)
data = check_response(resp)
console.print(f"[green]✓[/green] Rolled back to {data.get('version', version or 'previous')}")
console.print(
f"[green]✓[/green] Rolled back to {data.get('version', version or 'previous')}"
)
# ============================================================================
@@ -632,7 +654,9 @@ def fn_rollback(name: str, version: str):
@fn_group.command(name="traffic")
@click.argument("name")
@click.option("--version", "-v", multiple=True, help="Version:weight pairs (e.g., v1:90 v2:10)")
@click.option(
"--version", "-v", multiple=True, help="Version:weight pairs (e.g., v1:90 v2:10)"
)
def fn_traffic(name: str, version: tuple):
"""Configure traffic splitting between versions.
+30 -8
View File
@@ -90,7 +90,9 @@ def git_connect(provider, token):
console.print("[dim]Complete the OAuth flow in your browser.[/dim]")
console.print("[dim]Then run 'hanzo git providers' to verify.[/dim]")
except Exception:
console.print("[yellow]Could not open browser. Visit the URL above manually.[/yellow]")
console.print(
"[yellow]Could not open browser. Visit the URL above manually.[/yellow]"
)
@git_group.command(name="providers")
@@ -106,7 +108,9 @@ def git_providers():
if data is None:
return
providers = data if isinstance(data, list) else data.get("providers", data.get("data", []))
providers = (
data if isinstance(data, list) else data.get("providers", data.get("data", []))
)
if not providers:
console.print("[dim]No git providers connected.[/dim]")
@@ -173,9 +177,15 @@ def git_repos(provider):
data = client.get(git_url())
if data is None:
return
providers = data if isinstance(data, list) else data.get("providers", data.get("data", []))
providers = (
data
if isinstance(data, list)
else data.get("providers", data.get("data", []))
)
if not providers:
console.print("[yellow]No git providers connected. Run 'hanzo git connect github' first.[/yellow]")
console.print(
"[yellow]No git providers connected. Run 'hanzo git connect github' first.[/yellow]"
)
return
provider = str(providers[0].get("_id") or providers[0].get("id", ""))
@@ -220,7 +230,11 @@ def git_branches(repo, provider):
data = client.get(git_url())
if data is None:
return
providers = data if isinstance(data, list) else data.get("providers", data.get("data", []))
providers = (
data
if isinstance(data, list)
else data.get("providers", data.get("data", []))
)
if not providers:
console.print("[yellow]No git providers connected.[/yellow]")
return
@@ -230,7 +244,9 @@ def git_branches(repo, provider):
if data is None:
return
branches = data if isinstance(data, list) else data.get("branches", data.get("data", []))
branches = (
data if isinstance(data, list) else data.get("branches", data.get("data", []))
)
if not branches:
console.print(f"[dim]No branches found for '{repo}'.[/dim]")
@@ -277,7 +293,11 @@ def git_link(repo, container, branch, provider):
data = client.get(git_url())
if data is None:
return
providers = data if isinstance(data, list) else data.get("providers", data.get("data", []))
providers = (
data
if isinstance(data, list)
else data.get("providers", data.get("data", []))
)
if not providers:
console.print("[yellow]No git providers connected.[/yellow]")
return
@@ -289,7 +309,9 @@ def git_link(repo, container, branch, provider):
if data is None:
return
containers = data if isinstance(data, list) else data.get("containers", data.get("data", []))
containers = (
data if isinstance(data, list) else data.get("containers", data.get("data", []))
)
cid = None
for c in containers:
cname = c.get("name", "")
+67 -21
View File
@@ -84,7 +84,11 @@ def events_list(limit: int, event: str, user: str):
for e in data.get("events", []):
props = e.get("properties", {})
props_str = ", ".join(f"{k}={v}" for k, v in props.items()) if isinstance(props, dict) else str(props)
props_str = (
", ".join(f"{k}={v}" for k, v in props.items())
if isinstance(props, dict)
else str(props)
)
table.add_row(
e.get("event", ""),
e.get("user_id", ""),
@@ -214,7 +218,9 @@ def funnels_create(name: str, steps: str):
step_list = [s.strip() for s in steps.split(",")]
resp = _request("post", "/v1/funnels", json={"name": name, "steps": step_list})
check_response(resp)
console.print(f"[green]✓[/green] Funnel '{name}' created with {len(step_list)} steps")
console.print(
f"[green]✓[/green] Funnel '{name}' created with {len(step_list)} steps"
)
@funnels.command(name="delete")
@@ -308,7 +314,9 @@ def web_stats(domain: str, period: str):
table.add_column("Visitors", style="dim")
for p in pages[:10]:
table.add_row(p.get("path", ""), str(p.get("views", 0)), str(p.get("visitors", 0)))
table.add_row(
p.get("path", ""), str(p.get("views", 0)), str(p.get("visitors", 0))
)
console.print(table)
@@ -377,7 +385,9 @@ def flags_create(key: str, description: str, rollout: int):
@click.option("--rollout", "-r", default=100, help="Rollout percentage")
def flags_enable(flag_key: str, rollout: int):
"""Enable a feature flag."""
resp = _request("patch", f"/v1/flags/{flag_key}", json={"enabled": True, "rollout": rollout})
resp = _request(
"patch", f"/v1/flags/{flag_key}", json={"enabled": True, "rollout": rollout}
)
check_response(resp)
console.print(f"[green]✓[/green] Flag '{flag_key}' enabled at {rollout}%")
@@ -412,7 +422,11 @@ def tests():
@tests.command(name="list")
@click.option("--status", type=click.Choice(["running", "completed", "draft", "all"]), default="all")
@click.option(
"--status",
type=click.Choice(["running", "completed", "draft", "all"]),
default="all",
)
def tests_list(status: str):
"""List A/B tests."""
params: dict = {}
@@ -430,7 +444,9 @@ def tests_list(status: str):
for t in data.get("tests", []):
st = t.get("status", "draft")
st_style = {"running": "green", "completed": "cyan", "draft": "dim"}.get(st, "white")
st_style = {"running": "green", "completed": "cyan", "draft": "dim"}.get(
st, "white"
)
winner = t.get("winner", "-")
table.add_row(
t.get("name", ""),
@@ -445,20 +461,28 @@ def tests_list(status: str):
@tests.command(name="create")
@click.option("--name", "-n", prompt=True, help="Test name")
@click.option("--variants", "-v", default="control,treatment", help="Comma-separated variants")
@click.option(
"--variants", "-v", default="control,treatment", help="Comma-separated variants"
)
@click.option("--metric", "-m", required=True, help="Primary metric")
@click.option("--traffic", "-t", default=100, help="Traffic percentage")
def tests_create(name: str, variants: str, metric: str, traffic: int):
"""Create an A/B test."""
variant_list = [v.strip() for v in variants.split(",")]
resp = _request("post", "/v1/tests", json={
"name": name,
"variants": variant_list,
"primary_metric": metric,
"traffic_percent": traffic,
})
resp = _request(
"post",
"/v1/tests",
json={
"name": name,
"variants": variant_list,
"primary_metric": metric,
"traffic_percent": traffic,
},
)
check_response(resp)
console.print(f"[green]✓[/green] A/B test '{name}' created with {len(variant_list)} variants")
console.print(
f"[green]✓[/green] A/B test '{name}' created with {len(variant_list)} variants"
)
@tests.command(name="start")
@@ -481,7 +505,9 @@ def tests_stop(test_name: str):
@tests.command(name="results")
@click.argument("test_name")
@click.option("--format", "-f", "fmt", type=click.Choice(["table", "json"]), default="table")
@click.option(
"--format", "-f", "fmt", type=click.Choice(["table", "json"]), default="table"
)
def tests_results(test_name: str, fmt: str):
"""View A/B test results."""
resp = _request("get", f"/v1/tests/{test_name}/results")
@@ -510,7 +536,11 @@ def tests_results(test_name: str, fmt: str):
for v in variants:
is_winner = v.get("is_winner", False)
name_str = f"[bold]{v.get('name', '')}[/bold] *" if is_winner else v.get("name", "")
name_str = (
f"[bold]{v.get('name', '')}[/bold] *"
if is_winner
else v.get("name", "")
)
table.add_row(
name_str,
str(v.get("users", 0)),
@@ -543,7 +573,11 @@ def campaigns():
@campaigns.command(name="list")
@click.option("--status", type=click.Choice(["active", "draft", "completed", "all"]), default="all")
@click.option(
"--status",
type=click.Choice(["active", "draft", "completed", "all"]),
default="all",
)
def campaigns_list(status: str):
"""List campaigns."""
params: dict = {}
@@ -561,7 +595,9 @@ def campaigns_list(status: str):
for c in data.get("campaigns", []):
st = c.get("status", "draft")
st_style = {"active": "green", "completed": "cyan", "draft": "dim"}.get(st, "white")
st_style = {"active": "green", "completed": "cyan", "draft": "dim"}.get(
st, "white"
)
table.add_row(
c.get("name", ""),
c.get("type", ""),
@@ -575,11 +611,19 @@ def campaigns_list(status: str):
@campaigns.command(name="create")
@click.option("--name", "-n", prompt=True, help="Campaign name")
@click.option("--type", "-t", "campaign_type", type=click.Choice(["email", "push", "sms", "in-app"]), default="email")
@click.option(
"--type",
"-t",
"campaign_type",
type=click.Choice(["email", "push", "sms", "in-app"]),
default="email",
)
@click.option("--segment", "-s", help="Target segment")
@click.option("--subject", help="Email subject")
@click.option("--body", "-b", help="Message body")
def campaigns_create(name: str, campaign_type: str, segment: str, subject: str, body: str):
def campaigns_create(
name: str, campaign_type: str, segment: str, subject: str, body: str
):
"""Create a campaign."""
payload: dict = {"name": name, "type": campaign_type}
if segment:
@@ -604,7 +648,9 @@ def campaigns_send(campaign_name: str, schedule: str):
resp = _request("post", f"/v1/campaigns/{campaign_name}/send", json=payload)
check_response(resp)
if schedule:
console.print(f"[green]✓[/green] Campaign '{campaign_name}' scheduled for {schedule}")
console.print(
f"[green]✓[/green] Campaign '{campaign_name}' scheduled for {schedule}"
)
else:
console.print(f"[green]✓[/green] Campaign '{campaign_name}' sent")
+93 -23
View File
@@ -27,7 +27,9 @@ def _get_iam_url() -> str:
def _get_iam_credentials() -> tuple[str, str]:
"""Get client_id and client_secret from env or defaults."""
client_id = os.getenv("IAM_CLIENT_ID", os.getenv("HANZO_IAM_CLIENT_ID", ""))
client_secret = os.getenv("IAM_CLIENT_SECRET", os.getenv("HANZO_IAM_CLIENT_SECRET", ""))
client_secret = os.getenv(
"IAM_CLIENT_SECRET", os.getenv("HANZO_IAM_CLIENT_SECRET", "")
)
if not client_id or not client_secret:
# Try loading from auth file
from pathlib import Path
@@ -44,7 +46,11 @@ def _get_iam_credentials() -> tuple[str, str]:
def _iam_request(
method: str, path: str, params: dict | None = None, json_body: dict | None = None, auth_params: bool = True
method: str,
path: str,
params: dict | None = None,
json_body: dict | None = None,
auth_params: bool = True,
) -> dict:
"""Make authenticated request to IAM API."""
url = _get_iam_url().rstrip("/") + path
@@ -173,7 +179,9 @@ def configure(url: str, client_id: str, client_secret: str, org: str):
if not client_id:
client_id = Prompt.ask("Client ID", default=auth.get("iam_client_id", ""))
if not client_secret:
client_secret = Prompt.ask("Client Secret", password=True, default=auth.get("iam_client_secret", ""))
client_secret = Prompt.ask(
"Client Secret", password=True, default=auth.get("iam_client_secret", "")
)
auth["iam_url"] = url
auth["iam_client_id"] = client_id
@@ -194,9 +202,14 @@ def configure(url: str, client_id: str, client_secret: str, org: str):
if data.get("status") != "error":
console.print("[green]✓[/green] Connected to IAM successfully")
else:
console.print("[yellow]⚠[/yellow] Connected but got error: " + data.get("msg", "unknown"))
console.print(
"[yellow]⚠[/yellow] Connected but got error: "
+ data.get("msg", "unknown")
)
except SystemExit:
console.print("[yellow]⚠[/yellow] Could not verify connection (credentials saved anyway)")
console.print(
"[yellow]⚠[/yellow] Could not verify connection (credentials saved anyway)"
)
@iam_group.command(name="status")
@@ -210,8 +223,13 @@ def iam_status():
table.add_column("Value", style="white")
table.add_row("IAM URL", url)
table.add_row("Client ID", client_id[:12] + "..." if client_id else "[red]Not set[/red]")
table.add_row("Client Secret", "****" + client_secret[-4:] if client_secret else "[red]Not set[/red]")
table.add_row(
"Client ID", client_id[:12] + "..." if client_id else "[red]Not set[/red]"
)
table.add_row(
"Client Secret",
"****" + client_secret[-4:] if client_secret else "[red]Not set[/red]",
)
if client_id and client_secret:
try:
@@ -304,9 +322,13 @@ def users_get(username: str, org: str):
if user.get("roles"):
lines.append(f"[cyan]Roles:[/cyan] {', '.join(str(r) for r in user['roles'])}")
if user.get("groups"):
lines.append(f"[cyan]Groups:[/cyan] {', '.join(str(g) for g in user['groups'])}")
lines.append(
f"[cyan]Groups:[/cyan] {', '.join(str(g) for g in user['groups'])}"
)
console.print(Panel("\n".join(lines), title=f"User: {username}", border_style="cyan"))
console.print(
Panel("\n".join(lines), title=f"User: {username}", border_style="cyan")
)
@users.command(name="create")
@@ -317,7 +339,15 @@ def users_get(username: str, org: str):
@click.option("--phone", help="Phone number")
@click.option("--org", "-o", default="hanzo", help="Organization")
@click.option("--admin", is_flag=True, help="Make admin")
def users_create(username: str, email: str, password: str, name: str, phone: str, org: str, admin: bool):
def users_create(
username: str,
email: str,
password: str,
name: str,
phone: str,
org: str,
admin: bool,
):
"""Create a new user."""
user_obj = {
"owner": org,
@@ -355,7 +385,9 @@ def users_create(username: str, email: str, password: str, name: str, phone: str
if resp.status_code == 200 and resp.json().get("status") == "ok":
console.print("[green]✓[/green] Password set")
else:
console.print(f"[yellow]⚠[/yellow] Password may not have been set: {resp.json().get('msg', '')}")
console.print(
f"[yellow]⚠[/yellow] Password may not have been set: {resp.json().get('msg', '')}"
)
except Exception as e:
console.print(f"[yellow]⚠[/yellow] Could not set password: {e}")
@@ -370,7 +402,14 @@ def users_create(username: str, email: str, password: str, name: str, phone: str
@click.option("--forbidden/--no-forbidden", default=None, help="Ban/unban user")
@click.option("--org", "-o", default="hanzo", help="Organization")
def users_update(
username: str, email: str, name: str, phone: str, password: str, admin: bool, forbidden: bool, org: str
username: str,
email: str,
name: str,
phone: str,
password: str,
admin: bool,
forbidden: bool,
org: str,
):
"""Update user fields."""
# First get existing user
@@ -394,7 +433,12 @@ def users_update(
if forbidden is not None:
user_obj["isForbidden"] = forbidden
data = _iam_request("POST", "/api/update-user", params={"id": f"{org}/{username}"}, json_body=user_obj)
data = _iam_request(
"POST",
"/api/update-user",
params={"id": f"{org}/{username}"},
json_body=user_obj,
)
_check_response(data, "update user")
console.print(f"[green]✓[/green] User '{username}' updated")
@@ -417,7 +461,9 @@ def users_update(
if resp.status_code == 200 and resp.json().get("status") == "ok":
console.print("[green]✓[/green] Password updated")
else:
console.print(f"[yellow]⚠[/yellow] Password update issue: {resp.json().get('msg', '')}")
console.print(
f"[yellow]⚠[/yellow] Password update issue: {resp.json().get('msg', '')}"
)
except Exception as e:
console.print(f"[yellow]⚠[/yellow] Could not update password: {e}")
@@ -519,7 +565,9 @@ def orgs_get(name: str):
f"[cyan]Created:[/cyan] {org.get('createdTime', '')}",
]
console.print(Panel("\n".join(lines), title=f"Organization: {name}", border_style="cyan"))
console.print(
Panel("\n".join(lines), title=f"Organization: {name}", border_style="cyan")
)
@orgs.command(name="create")
@@ -550,7 +598,9 @@ def orgs_delete(name: str, yes: bool):
if not yes:
from rich.prompt import Confirm
if not Confirm.ask(f"[red]Delete organization '{name}'? This cannot be undone.[/red]"):
if not Confirm.ask(
f"[red]Delete organization '{name}'? This cannot be undone.[/red]"
):
return
org_obj = {"owner": "admin", "name": name}
@@ -627,7 +677,9 @@ def providers_get(name: str, owner: str):
f"[cyan]Created:[/cyan] {prov.get('createdTime', '')}",
]
console.print(Panel("\n".join(lines), title=f"Provider: {name}", border_style="cyan"))
console.print(
Panel("\n".join(lines), title=f"Provider: {name}", border_style="cyan")
)
@providers.command(name="create")
@@ -662,7 +714,13 @@ def providers_get(name: str, owner: str):
@click.option("--scopes", help="OAuth scopes")
@click.option("--owner", default="admin", help="Provider owner")
def providers_create(
name: str, ptype: str, display_name: str, client_id: str, client_secret: str, scopes: str, owner: str
name: str,
ptype: str,
display_name: str,
client_id: str,
client_secret: str,
scopes: str,
owner: str,
):
"""Create an authentication provider."""
prov_obj = {
@@ -737,7 +795,11 @@ def apps_list(org: str):
a.get("name", ""),
a.get("displayName", ""),
a.get("organization", ""),
a.get("clientId", "")[:20] + "..." if len(a.get("clientId", "")) > 20 else a.get("clientId", ""),
(
a.get("clientId", "")[:20] + "..."
if len(a.get("clientId", "")) > 20
else a.get("clientId", "")
),
str(providers_count),
)
@@ -788,7 +850,9 @@ def apps_get(name: str, org: str):
required = s.get("required", False) if isinstance(s, dict) else False
lines.append(f" - {sname} {'(required)' if required else '(optional)'}")
console.print(Panel("\n".join(lines), title=f"Application: {name}", border_style="cyan"))
console.print(
Panel("\n".join(lines), title=f"Application: {name}", border_style="cyan")
)
# ============================================================================
@@ -1048,7 +1112,9 @@ def tokens_inspect(token: str):
token = auth.get("token", "")
if not token:
console.print("[red]No token provided and none found in ~/.hanzo/auth.json[/red]")
console.print(
"[red]No token provided and none found in ~/.hanzo/auth.json[/red]"
)
return
parts = token.split(".")
@@ -1081,14 +1147,18 @@ def tokens_inspect(token: str):
console.print(f"\n[red]Token EXPIRED at {exp}[/red]")
else:
delta = exp - now
console.print(f"\n[green]Token valid until {exp} ({delta} remaining)[/green]")
console.print(
f"\n[green]Token valid until {exp} ({delta} remaining)[/green]"
)
except Exception:
pass
@tokens.command(name="exchange")
@click.argument("code")
@click.option("--redirect-uri", "-r", default="", help="Redirect URI used in authorization")
@click.option(
"--redirect-uri", "-r", default="", help="Redirect URI used in authorization"
)
def tokens_exchange(code: str, redirect_uri: str):
"""Exchange authorization code for access token."""
client_id, client_secret = _get_iam_credentials()
+105 -29
View File
@@ -241,8 +241,14 @@ def install_list(installed: bool, available: bool):
is_installed = shutil.which(binary) is not None
if is_installed:
try:
result = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=5)
version = result.stdout.strip().split()[-1] if result.returncode == 0 else "?"
result = subprocess.run(
[binary, "--version"], capture_output=True, text=True, timeout=5
)
version = (
result.stdout.strip().split()[-1]
if result.returncode == 0
else "?"
)
except Exception:
version = "?"
@@ -251,7 +257,14 @@ def install_list(installed: bool, available: bool):
if available and is_installed:
continue
table.add_row(tool_id, tool["name"], tool["language"], tool["source"], "✓" if is_installed else "", version)
table.add_row(
tool_id,
tool["name"],
tool["language"],
tool["source"],
"✓" if is_installed else "",
version,
)
console.print(table)
@@ -426,7 +439,9 @@ def _install_github_release(tool: dict, version: str, force: bool):
break
if not download_url:
console.print(f"[yellow]No pre-built binary found, building from source...[/yellow]")
console.print(
f"[yellow]No pre-built binary found, building from source...[/yellow]"
)
_install_cargo(tool, version, force)
return
@@ -767,7 +782,9 @@ def install_doctor():
for name, cmd, required in checks:
try:
result = subprocess.run(cmd.split(), capture_output=True, text=True, timeout=5)
result = subprocess.run(
cmd.split(), capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
version = result.stdout.strip().split()[-1]
console.print(f" [green]✓[/green] {name}: {version}")
@@ -912,19 +929,25 @@ def install_ide(target: str, install_all: bool, list_only: bool):
try:
with open(config_path) as f:
config = json.load(f)
has_hanzo = "hanzo" in config.get("mcpServers", {}) or "hanzo-mcp" in config.get(
has_hanzo = "hanzo" in config.get(
"mcpServers", {}
)
) or "hanzo-mcp" in config.get("mcpServers", {})
status = (
"[green]✓ hanzo-mcp configured[/green]" if has_hanzo else "[yellow]○ no hanzo-mcp[/yellow]"
"[green]✓ hanzo-mcp configured[/green]"
if has_hanzo
else "[yellow]○ no hanzo-mcp[/yellow]"
)
except Exception:
status = "[dim]○ config exists[/dim]"
console.print(f" [green]✓[/green] {app_name}: {status}")
elif parent_exists:
console.print(f" [yellow]○[/yellow] {app_name}: [dim]app installed, no MCP config[/dim]")
console.print(
f" [yellow]○[/yellow] {app_name}: [dim]app installed, no MCP config[/dim]"
)
else:
console.print(f" [dim]○[/dim] {app_name}: [dim]not installed[/dim]")
console.print(
f" [dim]○[/dim] {app_name}: [dim]not installed[/dim]"
)
console.print()
console.print("[dim]Run: hanzo install ide <name> to configure[/dim]")
@@ -978,7 +1001,9 @@ def install_ide(target: str, install_all: bool, list_only: bool):
@install_group.command(name="browser")
@click.argument("browser", required=False)
@click.option("--list", "list_only", is_flag=True, help="List browser extension install URLs")
@click.option(
"--list", "list_only", is_flag=True, help="List browser extension install URLs"
)
def install_browser(browser: str, list_only: bool):
"""Install Hanzo browser extension.
@@ -1075,7 +1100,9 @@ def install_ai(app: str, list_only: bool):
from click import Context
ctx = Context(install_ide)
ctx.invoke(install_ide, target=app, install_all=False, list_only=list_only or not app)
ctx.invoke(
install_ide, target=app, install_all=False, list_only=list_only or not app
)
@install_group.command(name="all")
@@ -1167,7 +1194,9 @@ NANOBROWSER_PATH = Path.home() / "work" / "nanobrowser" / "nanobrowser"
@install_group.command(name="nanobrowser")
@click.option("--build", "-b", is_flag=True, help="Build from source")
@click.option("--dev", is_flag=True, help="Run in development mode")
@click.option("--open", "open_browser", is_flag=True, help="Open Chrome with extension loaded")
@click.option(
"--open", "open_browser", is_flag=True, help="Open Chrome with extension loaded"
)
def install_nanobrowser(build: bool, dev: bool, open_browser: bool):
"""Install/build Nanobrowser - lightweight AI browser for agents.
@@ -1198,7 +1227,9 @@ def install_nanobrowser(build: bool, dev: bool, open_browser: bool):
console.print(f"Expected at: {nanobrowser_dir}")
console.print()
console.print("Clone it with:")
console.print(f" git clone https://github.com/nanobrowser/nanobrowser {nanobrowser_dir}")
console.print(
f" git clone https://github.com/nanobrowser/nanobrowser {nanobrowser_dir}"
)
return
dist_dir = nanobrowser_dir / "dist"
@@ -1208,7 +1239,12 @@ def install_nanobrowser(build: bool, dev: bool, open_browser: bool):
# Install dependencies
console.print(" Installing dependencies...")
result = subprocess.run(["pnpm", "install"], cwd=str(nanobrowser_dir), capture_output=True, text=True)
result = subprocess.run(
["pnpm", "install"],
cwd=str(nanobrowser_dir),
capture_output=True,
text=True,
)
if result.returncode != 0:
console.print(f"[red]Failed to install dependencies: {result.stderr}[/red]")
return
@@ -1219,7 +1255,12 @@ def install_nanobrowser(build: bool, dev: bool, open_browser: bool):
subprocess.run(["pnpm", "dev"], cwd=str(nanobrowser_dir))
else:
console.print(" Building extension...")
result = subprocess.run(["pnpm", "build"], cwd=str(nanobrowser_dir), capture_output=True, text=True)
result = subprocess.run(
["pnpm", "build"],
cwd=str(nanobrowser_dir),
capture_output=True,
text=True,
)
if result.returncode != 0:
console.print(f"[red]Build failed: {result.stderr}[/red]")
return
@@ -1227,7 +1268,9 @@ def install_nanobrowser(build: bool, dev: bool, open_browser: bool):
elif open_browser:
if not dist_dir.exists():
console.print("[yellow]Extension not built yet. Run with --build first.[/yellow]")
console.print(
"[yellow]Extension not built yet. Run with --build first.[/yellow]"
)
return
# Open Chrome with extension loaded (macOS)
@@ -1254,7 +1297,9 @@ def install_nanobrowser(build: bool, dev: bool, open_browser: bool):
# Status check
console.print("[bold]Nanobrowser Status[/bold]")
console.print(f" Location: {nanobrowser_dir}")
console.print(f" Built: {'[green]✓[/green]' if dist_dir.exists() else '[yellow]○[/yellow] (run --build)'}")
console.print(
f" Built: {'[green]✓[/green]' if dist_dir.exists() else '[yellow]○[/yellow] (run --build)'}"
)
# Get version from package.json
package_json = nanobrowser_dir / "package.json"
@@ -1286,8 +1331,12 @@ RUNTIME_PATH = Path.home() / "work" / "hanzo" / "runtime"
@install_group.command(name="runtime")
@click.option("--setup", "-s", is_flag=True, help="Set up the runtime environment")
@click.option("--sdk", type=click.Choice(["python", "typescript", "both"]), help="Install SDK")
@click.option("--computer-use", is_flag=True, help="Set up VNC desktop for computer use")
@click.option(
"--sdk", type=click.Choice(["python", "typescript", "both"]), help="Install SDK"
)
@click.option(
"--computer-use", is_flag=True, help="Set up VNC desktop for computer use"
)
def install_runtime(setup: bool, sdk: str, computer_use: bool):
"""Configure Hanzo Runtime - sandboxed agent execution.
@@ -1320,7 +1369,10 @@ def install_runtime(setup: bool, sdk: str, computer_use: bool):
if shutil.which("uv"):
subprocess.run(["uv", "pip", "install", "hanzo-runtime"], check=True)
else:
subprocess.run([sys.executable, "-m", "pip", "install", "hanzo-runtime"], check=True)
subprocess.run(
[sys.executable, "-m", "pip", "install", "hanzo-runtime"],
check=True,
)
console.print("[green]✓[/green] Python SDK installed")
if sdk in ("typescript", "both"):
@@ -1376,7 +1428,13 @@ def install_runtime(setup: bool, sdk: str, computer_use: bool):
cli_dir = runtime_dir / "apps" / "cli"
if cli_dir.exists():
result = subprocess.run(
["go", "build", "-o", str(Path.home() / ".hanzo" / "bin" / "hanzo-runtime"), "."],
[
"go",
"build",
"-o",
str(Path.home() / ".hanzo" / "bin" / "hanzo-runtime"),
".",
],
cwd=str(cli_dir),
capture_output=True,
text=True,
@@ -1417,7 +1475,11 @@ def install_runtime(setup: bool, sdk: str, computer_use: bool):
# Python SDK
try:
result = subprocess.run(
[sys.executable, "-c", "import hanzo_runtime; print(hanzo_runtime.__version__)"],
[
sys.executable,
"-c",
"import hanzo_runtime; print(hanzo_runtime.__version__)",
],
capture_output=True,
text=True,
)
@@ -1429,7 +1491,9 @@ def install_runtime(setup: bool, sdk: str, computer_use: bool):
console.print(" [dim]○[/dim] Python: not installed")
# TypeScript SDK
result = subprocess.run(["npm", "list", "-g", "@hanzo/runtime"], capture_output=True, text=True)
result = subprocess.run(
["npm", "list", "-g", "@hanzo/runtime"], capture_output=True, text=True
)
if "@hanzo/runtime" in result.stdout:
console.print(f" [green]✓[/green] TypeScript: installed")
else:
@@ -1514,7 +1578,9 @@ def install_cas(component: str, setup: bool, start: bool):
if not comp_path.exists():
console.print(f"[red]{info['name']} not found at: {comp_path}[/red]")
console.print(f"Clone: git clone https://github.com/{component}/{component} {comp_path}")
console.print(
f"Clone: git clone https://github.com/{component}/{component} {comp_path}"
)
return
if setup:
@@ -1529,7 +1595,9 @@ def install_cas(component: str, setup: bool, start: bool):
# Build
console.print(" Building...")
result = subprocess.run(["go", "build", "."], cwd=str(comp_path), capture_output=True, text=True)
result = subprocess.run(
["go", "build", "."], cwd=str(comp_path), capture_output=True, text=True
)
if result.returncode == 0:
console.print(f"[green]✓[/green] {info['name']} built")
else:
@@ -1576,9 +1644,17 @@ def install_cas(component: str, setup: bool, start: bool):
built = binary and binary.exists() if binary else False
status = "[green]✓[/green]" if exists else "[dim]○[/dim]"
build_status = " [green](built)[/green]" if built else " [yellow](not built)[/yellow]" if exists else ""
build_status = (
" [green](built)[/green]"
if built
else " [yellow](not built)[/yellow]" if exists else ""
)
console.print(f" {status} {info['name']}: {info['description']}{build_status}")
console.print(
f" {status} {info['name']}: {info['description']}{build_status}"
)
console.print()
console.print("[dim]Set up a component: hanzo install cas <component> --setup[/dim]")
console.print(
"[dim]Set up a component: hanzo install cas <component> --setup[/dim]"
)
+21 -6
View File
@@ -57,7 +57,9 @@ def jobs_group():
@click.option("--priority", type=int, default=5, help="Priority (1-10)")
@click.option("--delay", "-d", help="Delay before execution (e.g., 5m, 1h)")
@click.option("--wait", "-w", is_flag=True, help="Wait for completion")
def jobs_run(name: str, payload: str, queue: str, priority: int, delay: str, wait: bool):
def jobs_run(
name: str, payload: str, queue: str, priority: int, delay: str, wait: bool
):
"""Run a background job."""
body = {"name": name, "queue": queue, "priority": priority}
if payload:
@@ -88,7 +90,12 @@ def jobs_run(name: str, payload: str, queue: str, priority: int, delay: str, wai
@jobs_group.command(name="list")
@click.option("--status", "-s", type=click.Choice(["pending", "active", "completed", "failed", "all"]), default="all")
@click.option(
"--status",
"-s",
type=click.Choice(["pending", "active", "completed", "failed", "all"]),
default="all",
)
@click.option("--queue", "-q", help="Filter by queue")
@click.option("--limit", "-n", default=20, help="Max results")
def jobs_list(status: str, queue: str, limit: int):
@@ -154,7 +161,9 @@ def jobs_logs(job_id: str, follow: bool, tail: int):
ts = str(line.get("timestamp", ""))[:19]
level = line.get("level", "info")
msg = line.get("message", "")
style = "red" if level == "error" else "yellow" if level == "warn" else "dim"
style = (
"red" if level == "error" else "yellow" if level == "warn" else "dim"
)
console.print(f"[dim]{ts}[/dim] [{style}]{level}[/{style}] {msg}")
else:
console.print(str(line))
@@ -234,7 +243,9 @@ def cron():
@cron.command(name="list")
@click.option("--status", "-s", type=click.Choice(["active", "paused", "all"]), default="all")
@click.option(
"--status", "-s", type=click.Choice(["active", "paused", "all"]), default="all"
)
def cron_list(status: str):
"""List cron schedules."""
params = {}
@@ -265,12 +276,16 @@ def cron_list(status: str):
console.print(table)
if not items:
console.print("[dim]No cron schedules found. Create one with 'hanzo jobs cron create'[/dim]")
console.print(
"[dim]No cron schedules found. Create one with 'hanzo jobs cron create'[/dim]"
)
@cron.command(name="create")
@click.argument("name")
@click.option("--schedule", "-s", required=True, help="Cron expression (e.g., '0 * * * *')")
@click.option(
"--schedule", "-s", required=True, help="Cron expression (e.g., '0 * * * *')"
)
@click.option("--job", "-j", required=True, help="Job name to run")
@click.option("--payload", "-p", help="JSON payload")
@click.option("--timezone", "-tz", default="UTC", help="Timezone")
+55 -17
View File
@@ -102,7 +102,9 @@ def cluster():
@cluster.command(name="create")
@click.argument("name")
@click.option("--region", "-r", help="Region")
@click.option("--version", "-v", "k8s_version", default="1.29", help="Kubernetes version")
@click.option(
"--version", "-v", "k8s_version", default="1.29", help="Kubernetes version"
)
@click.option("--nodes", "-n", default=3, help="Number of nodes")
@click.option("--node-type", "-t", default="standard-2", help="Node type")
@click.option("--ha", is_flag=True, help="High availability control plane")
@@ -164,10 +166,14 @@ def cluster_list(region):
if data is None:
return
clusters = data if isinstance(data, list) else data.get("clusters", data.get("data", []))
clusters = (
data if isinstance(data, list) else data.get("clusters", data.get("data", []))
)
if not clusters:
console.print("[dim]No clusters found. Create one with 'hanzo k8s cluster create'[/dim]")
console.print(
"[dim]No clusters found. Create one with 'hanzo k8s cluster create'[/dim]"
)
return
table = Table(title="Kubernetes Clusters", box=box.ROUNDED)
@@ -240,7 +246,9 @@ def cluster_delete(name, force):
if not force:
from rich.prompt import Confirm
if not Confirm.ask(f"[red]Delete cluster '{name}'? This cannot be undone.[/red]"):
if not Confirm.ask(
f"[red]Delete cluster '{name}'? This cannot be undone.[/red]"
):
return
client = _get_client(timeout=60)
@@ -387,7 +395,9 @@ def fleet_create(name, description):
console.print(f"[green]✓[/green] Fleet '{name}' created")
if description:
console.print(f" Description: {description}")
console.print("[dim]Fleet management is handled at the cluster level in the current PaaS release.[/dim]")
console.print(
"[dim]Fleet management is handled at the cluster level in the current PaaS release.[/dim]"
)
@fleet.command(name="list")
@@ -404,7 +414,9 @@ def fleet_list():
if data is None:
return
clusters = data if isinstance(data, list) else data.get("clusters", data.get("data", []))
clusters = (
data if isinstance(data, list) else data.get("clusters", data.get("data", []))
)
if not clusters:
console.print("[dim]No fleets found[/dim]")
@@ -450,7 +462,9 @@ def fleet_remove(fleet, cluster_name):
@click.argument("fleet")
@click.option("--manifest", "-f", required=True, help="Manifest file or directory")
@click.option("--selector", "-l", help="Cluster selector (labels)")
@click.option("--strategy", type=click.Choice(["rolling", "all", "canary"]), default="rolling")
@click.option(
"--strategy", type=click.Choice(["rolling", "all", "canary"]), default="rolling"
)
def fleet_deploy(fleet, manifest, selector, strategy):
"""Deploy workloads across fleet.
@@ -561,7 +575,9 @@ def k8s_pods(name, all_namespaces):
if data is None:
return
pods = data if isinstance(data, list) else data.get("pods", data.get("data", []))
pods = (
data if isinstance(data, list) else data.get("pods", data.get("data", []))
)
title = f"Pods for '{name}'"
else:
# List all containers, then get pods
@@ -569,14 +585,22 @@ def k8s_pods(name, all_namespaces):
if data is None:
return
containers = data if isinstance(data, list) else data.get("containers", data.get("data", []))
containers = (
data
if isinstance(data, list)
else data.get("containers", data.get("data", []))
)
pods = []
for c in containers:
cid = c.get("_id") or c.get("iid") or c.get("id", "")
cname = c.get("name", "")
pod_data = client.get(f"{base_url}/{cid}/pods")
if pod_data:
pod_list = pod_data if isinstance(pod_data, list) else pod_data.get("pods", pod_data.get("data", []))
pod_list = (
pod_data
if isinstance(pod_data, list)
else pod_data.get("pods", pod_data.get("data", []))
)
for p in pod_list:
p["_container_name"] = cname
pods.extend(pod_list)
@@ -618,7 +642,9 @@ def k8s_services(all_namespaces):
if data is None:
return
containers = data if isinstance(data, list) else data.get("containers", data.get("data", []))
containers = (
data if isinstance(data, list) else data.get("containers", data.get("data", []))
)
if not containers:
console.print("[dim]No services found[/dim]")
@@ -703,7 +729,9 @@ def k8s_exec(pod, command, container, stdin, tty):
cmd = " ".join(command) if command else "/bin/sh"
console.print(f"[cyan]Executing in '{pod}': {cmd}[/cyan]")
console.print("[yellow]Remote exec requires direct kubectl access.[/yellow]")
console.print("[dim]Use 'hanzo k8s cluster kubeconfig NAME --merge' then 'kubectl exec'[/dim]")
console.print(
"[dim]Use 'hanzo k8s cluster kubeconfig NAME --merge' then 'kubectl exec'[/dim]"
)
# ============================================================================
@@ -775,7 +803,9 @@ def config_list(secrets):
if data is None:
return
containers = data if isinstance(data, list) else data.get("containers", data.get("data", []))
containers = (
data if isinstance(data, list) else data.get("containers", data.get("data", []))
)
kind = "Secrets" if secrets else "ConfigMaps"
table = Table(title=f"Container Variables ({kind})", box=box.ROUNDED)
@@ -859,7 +889,9 @@ def ingress_list(all_namespaces):
if data is None:
return
containers = data if isinstance(data, list) else data.get("containers", data.get("data", []))
containers = (
data if isinstance(data, list) else data.get("containers", data.get("data", []))
)
table = Table(title="Ingress (Custom Domains)", box=box.ROUNDED)
table.add_column("Container", style="cyan")
@@ -875,7 +907,9 @@ def ingress_list(all_namespaces):
found = True
port = str(networking.get("containerPort", ""))
tls_secret = networking.get("tlsSecret", "")
table.add_row(c.get("name", ""), domain, port, "Yes" if tls_secret else "No")
table.add_row(
c.get("name", ""), domain, port, "Yes" if tls_secret else "No"
)
if found:
console.print(table)
@@ -952,7 +986,9 @@ def nodepool_list(cluster):
if data is None:
return
pools = data if isinstance(data, list) else data.get("nodePools", data.get("data", []))
pools = (
data if isinstance(data, list) else data.get("nodePools", data.get("data", []))
)
if not pools:
console.print("[dim]No node pools found[/dim]")
@@ -988,7 +1024,9 @@ def nodepool_scale(name, cluster, nodes):
return
console.print(f"[cyan]Scaling pool '{name}' to {nodes} nodes...[/cyan]")
result = client.put(f"{cluster_url(cluster)}/nodepools/{name}", {"nodeCount": nodes})
result = client.put(
f"{cluster_url(cluster)}/nodepools/{name}", {"nodeCount": nodes}
)
if result is None:
return
+12 -4
View File
@@ -56,7 +56,9 @@ def kv_group():
@click.argument("name")
@click.option("--region", "-r", multiple=True, help="Regions for replication")
@click.option("--max-size", default="1GB", help="Max store size")
@click.option("--eviction", type=click.Choice(["lru", "lfu", "ttl", "none"]), default="lru")
@click.option(
"--eviction", type=click.Choice(["lru", "lfu", "ttl", "none"]), default="lru"
)
def kv_create(name: str, region: tuple, max_size: str, eviction: str):
"""Create a KV store."""
payload = {"name": name, "max_size": max_size, "eviction_policy": eviction}
@@ -98,7 +100,9 @@ def kv_list():
console.print(table)
if not stores:
console.print("[dim]No KV stores found. Create one with 'hanzo kv create'[/dim]")
console.print(
"[dim]No KV stores found. Create one with 'hanzo kv create'[/dim]"
)
@kv_group.command(name="delete")
@@ -177,7 +181,9 @@ def kv_del(keys: tuple, store: str):
@click.option("--limit", "-n", default=100, help="Max keys")
def kv_keys(store: str, pattern: str, limit: int):
"""List keys matching pattern."""
resp = _request("get", f"/v1/stores/{store}/keys", params={"pattern": pattern, "limit": limit})
resp = _request(
"get", f"/v1/stores/{store}/keys", params={"pattern": pattern, "limit": limit}
)
data = check_response(resp)
keys = data.get("keys", [])
@@ -240,7 +246,9 @@ def kv_mset(pairs: tuple, store: str):
def kv_ttl(key: str, store: str, set_ttl: str):
"""Get or set TTL for a key."""
if set_ttl:
resp = _request("put", f"/v1/stores/{store}/keys/{key}/ttl", json={"ttl": set_ttl})
resp = _request(
"put", f"/v1/stores/{store}/keys/{key}/ttl", json={"ttl": set_ttl}
)
check_response(resp)
console.print(f"[green]✓[/green] Set TTL for '{key}' to {set_ttl}")
else:
+6 -2
View File
@@ -96,7 +96,9 @@ async def tools(ctx, category: str):
# Display tools
for cat, tools in sorted(categories.items()):
table = Table(title=f"{cat.title()} Tools" if len(categories) > 1 else "MCP Tools")
table = Table(
title=f"{cat.title()} Tools" if len(categories) > 1 else "MCP Tools"
)
table.add_column("Name", style="cyan", no_wrap=True)
table.add_column("Description")
@@ -238,7 +240,9 @@ def install(ctx, path: str):
existing["mcpServers"]["hanzo-mcp"] = config["mcpServers"]["hanzo-mcp"]
config = existing
except Exception as e:
console.print(f"[yellow]Warning: Could not read existing config: {e}[/yellow]")
console.print(
f"[yellow]Warning: Could not read existing config: {e}[/yellow]"
)
# Write config
config_path.parent.mkdir(parents=True, exist_ok=True)
+21 -7
View File
@@ -50,7 +50,9 @@ async def start(
if not click.confirm("Continue without wallet?"):
return
miner = HanzoMiner(name=name, wallet=wallet, device=device, network=network, min_stake=min_stake)
miner = HanzoMiner(
name=name, wallet=wallet, device=device, network=network, min_stake=min_stake
)
with Progress(
SpinnerColumn(),
@@ -92,7 +94,9 @@ async def start(
if event["type"] == "log":
console.print(event["message"], end="")
elif event["type"] == "job":
console.print(f"[green]Job completed:[/green] {event['job_id']} (+{event['tokens']} tokens)")
console.print(
f"[green]Job completed:[/green] {event['job_id']} (+{event['tokens']} tokens)"
)
elif event["type"] == "error":
console.print(f"[red]Error:[/red] {event['message']}")
except KeyboardInterrupt:
@@ -105,7 +109,9 @@ async def start(
console.print("\n[cyan]Session Summary:[/cyan]")
console.print(f" Jobs completed: {final_stats.get('jobs_completed', 0)}")
console.print(f" Tokens earned: {final_stats.get('tokens_earned', 0)}")
console.print(f" Average job time: {final_stats.get('avg_job_time', 'N/A')}")
console.print(
f" Average job time: {final_stats.get('avg_job_time', 'N/A')}"
)
console.print("[green]✓[/green] Miner stopped")
@@ -201,11 +207,15 @@ async def status(ctx, name: str, detailed: bool):
console.print(f" Network: {stats.get('network', 'unknown')}")
console.print(f" Wallet: {stats.get('wallet', 'Not set')}")
console.print(f" Models: {', '.join(stats.get('models', []))}")
console.print(f" Memory: {stats.get('memory_used', 0)} / {stats.get('memory_total', 0)} MB")
console.print(
f" Memory: {stats.get('memory_used', 0)} / {stats.get('memory_total', 0)} MB"
)
console.print(f" CPU: {stats.get('cpu_percent', 0)}%")
if gpu := stats.get("gpu"):
console.print(f" GPU: {gpu['name']} ({gpu['memory_used']} / {gpu['memory_total']} MB)")
console.print(
f" GPU: {gpu['name']} ({gpu['memory_used']} / {gpu['memory_total']} MB)"
)
console.print(f"\n[cyan]Performance:[/cyan]")
console.print(f" Average job time: {stats.get('avg_job_time', 'N/A')}")
@@ -282,7 +292,9 @@ async def earnings(ctx, wallet: str, network: str):
if history := data.get("recent_jobs"):
console.print("\n[cyan]Recent Jobs:[/cyan]")
for job in history[:5]:
console.print(f" • {job['timestamp']}: +{job['tokens']} tokens ({job['model']})")
console.print(
f" • {job['timestamp']}: +{job['tokens']} tokens ({job['model']})"
)
@miner_group.command()
@@ -311,7 +323,9 @@ async def withdraw(ctx, amount: float, wallet: str, to: str, network: str):
with console.status("Processing withdrawal..."):
try:
result = await withdraw_earnings(wallet=wallet, amount=amount, destination=to, network=network)
result = await withdraw_earnings(
wallet=wallet, amount=amount, destination=to, network=network
)
console.print(f"[green]✓[/green] Withdrawal successful")
console.print(f" Transaction: {result['tx_hash']}")
+112 -29
View File
@@ -92,7 +92,9 @@ def notebooks_list():
console.print(table)
if not items:
console.print("[dim]No notebooks found. Create one with 'hanzo ml notebooks create'[/dim]")
console.print(
"[dim]No notebooks found. Create one with 'hanzo ml notebooks create'[/dim]"
)
@notebooks.command(name="create")
@@ -158,7 +160,11 @@ def training():
@training.command(name="list")
@click.option("--status", type=click.Choice(["running", "completed", "failed", "all"]), default="all")
@click.option(
"--status",
type=click.Choice(["running", "completed", "failed", "all"]),
default="all",
)
def training_list(status: str):
"""List training jobs."""
params = {}
@@ -179,7 +185,9 @@ def training_list(status: str):
for j in items:
j_status = j.get("status", "unknown")
status_style = {"running": "cyan", "completed": "green", "failed": "red"}.get(j_status, "white")
status_style = {"running": "cyan", "completed": "green", "failed": "red"}.get(
j_status, "white"
)
table.add_row(
str(j.get("id", ""))[:16],
j.get("name", "-"),
@@ -196,13 +204,24 @@ def training_list(status: str):
@training.command(name="create")
@click.option("--name", "-n", prompt=True, help="Job name")
@click.option("--framework", "-f", type=click.Choice(["pytorch", "tensorflow", "xgboost"]), default="pytorch")
@click.option(
"--framework",
"-f",
type=click.Choice(["pytorch", "tensorflow", "xgboost"]),
default="pytorch",
)
@click.option("--script", "-s", required=True, help="Training script path")
@click.option("--gpu", "-g", default="1", help="Number of GPUs")
@click.option("--instance", "-i", default="gpu-a10g", help="Instance type")
def training_create(name: str, framework: str, script: str, gpu: str, instance: str):
"""Create a training job."""
body = {"name": name, "framework": framework, "script": script, "gpu": gpu, "instance": instance}
body = {
"name": name,
"framework": framework,
"script": script,
"gpu": gpu,
"instance": instance,
}
console.print(f"[cyan]Creating training job '{name}'...[/cyan]")
resp = _request("post", "/v1/training", json=body)
@@ -231,7 +250,9 @@ def training_logs(job_id: str, follow: bool):
console.print(f"[cyan]Logs for job {job_id}:[/cyan]")
for line in lines:
if isinstance(line, dict):
console.print(f"[dim]{str(line.get('timestamp', ''))[:19]}[/dim] {line.get('message', '')}")
console.print(
f"[dim]{str(line.get('timestamp', ''))[:19]}[/dim] {line.get('message', '')}"
)
else:
console.print(str(line))
@@ -295,7 +316,9 @@ def pipelines_create(name: str, file: str):
with open(file) as f:
definition = json.load(f)
resp = _request("post", "/v1/pipelines", json={"name": name, "definition": definition})
resp = _request(
"post", "/v1/pipelines", json={"name": name, "definition": definition}
)
data = check_response(resp)
console.print(f"[green]✓[/green] Pipeline '{name}' created from {file}")
@@ -431,7 +454,11 @@ def registry_list(model: str):
for m in items:
stage = m.get("stage", "none")
style = "green" if stage == "production" else "yellow" if stage == "staging" else "dim"
style = (
"green"
if stage == "production"
else "yellow" if stage == "staging" else "dim"
)
table.add_row(
m.get("name", ""),
str(m.get("version", "-")),
@@ -471,17 +498,23 @@ def registry_push(name: str, path: str, framework: str, version: str):
def registry_pull(model_uri: str, output: str):
"""Pull a model from the registry."""
console.print(f"[cyan]Pulling model {model_uri}...[/cyan]")
resp = _request("post", "/v1/registry/pull", json={"uri": model_uri, "output": output})
resp = _request(
"post", "/v1/registry/pull", json={"uri": model_uri, "output": output}
)
data = check_response(resp)
console.print(f"[green]✓[/green] Model downloaded to {data.get('path', output)}")
@registry.command(name="promote")
@click.argument("model_uri")
@click.option("--stage", "-s", type=click.Choice(["staging", "production"]), required=True)
@click.option(
"--stage", "-s", type=click.Choice(["staging", "production"]), required=True
)
def registry_promote(model_uri: str, stage: str):
"""Promote a model version to a stage."""
resp = _request("post", "/v1/registry/promote", json={"uri": model_uri, "stage": stage})
resp = _request(
"post", "/v1/registry/promote", json={"uri": model_uri, "stage": stage}
)
check_response(resp)
console.print(f"[green]✓[/green] Model promoted to {stage}")
@@ -545,7 +578,11 @@ def experiments_list(namespace: str):
@experiments.command(name="runs")
@click.argument("experiment")
@click.option("--status", type=click.Choice(["running", "completed", "failed", "all"]), default="all")
@click.option(
"--status",
type=click.Choice(["running", "completed", "failed", "all"]),
default="all",
)
def experiments_runs(experiment: str, status: str):
"""List runs in an experiment."""
params = {}
@@ -564,8 +601,14 @@ def experiments_runs(experiment: str, status: str):
for r in items:
r_status = r.get("status", "unknown")
style = {"running": "cyan", "completed": "green", "failed": "red"}.get(r_status, "white")
metrics = json.dumps(r.get("metrics", {}), default=str)[:40] if r.get("metrics") else "-"
style = {"running": "cyan", "completed": "green", "failed": "red"}.get(
r_status, "white"
)
metrics = (
json.dumps(r.get("metrics", {}), default=str)[:40]
if r.get("metrics")
else "-"
)
table.add_row(
str(r.get("id", ""))[:16],
f"[{style}]{r_status}[/{style}]",
@@ -631,16 +674,27 @@ def tune():
@click.argument("name")
@click.option("--experiment", "-e", required=True, help="Parent experiment")
@click.option("--objective", "-o", required=True, help="Metric to optimize")
@click.option("--goal", "-g", type=click.Choice(["minimize", "maximize"]), default="minimize")
@click.option(
"--algorithm", "-a", type=click.Choice(["random", "grid", "bayesian", "hyperband", "tpe"]), default="bayesian"
"--goal", "-g", type=click.Choice(["minimize", "maximize"]), default="minimize"
)
@click.option(
"--algorithm",
"-a",
type=click.Choice(["random", "grid", "bayesian", "hyperband", "tpe"]),
default="bayesian",
)
@click.option("--max-trials", "-m", default=10, help="Maximum trials")
@click.option("--parallel-trials", "-p", default=2, help="Parallel trials")
@click.option("--config", "-c", help="Tuning config file (YAML)")
def tune_create(
name: str, experiment: str, objective: str, goal: str, algorithm: str,
max_trials: int, parallel_trials: int, config: str,
name: str,
experiment: str,
objective: str,
goal: str,
algorithm: str,
max_trials: int,
parallel_trials: int,
config: str,
):
"""Create a hyperparameter tuning job.
@@ -651,8 +705,12 @@ def tune_create(
hanzo ml tune create custom -e exp -o f1 -c tuning.yaml
"""
body = {
"name": name, "experiment": experiment, "objective": objective,
"goal": goal, "algorithm": algorithm, "max_trials": max_trials,
"name": name,
"experiment": experiment,
"objective": objective,
"goal": goal,
"algorithm": algorithm,
"max_trials": max_trials,
"parallel_trials": parallel_trials,
}
if config:
@@ -692,7 +750,9 @@ def tune_list(experiment: str):
for j in items:
j_status = j.get("status", "unknown")
style = {"running": "cyan", "completed": "green", "failed": "red"}.get(j_status, "white")
style = {"running": "cyan", "completed": "green", "failed": "red"}.get(
j_status, "white"
)
table.add_row(
j.get("name", ""),
j.get("algorithm", "-"),
@@ -847,12 +907,16 @@ def features_describe(name: str):
@features.command(name="ingest")
@click.argument("name")
@click.option("--from", "source", required=True, help="Data source (file, table, stream)")
@click.option(
"--from", "source", required=True, help="Data source (file, table, stream)"
)
@click.option("--mode", type=click.Choice(["append", "overwrite"]), default="append")
def features_ingest(name: str, source: str, mode: str):
"""Ingest data into feature group."""
console.print(f"[cyan]Ingesting into '{name}'...[/cyan]")
resp = _request("post", f"/v1/features/{name}/ingest", json={"source": source, "mode": mode})
resp = _request(
"post", f"/v1/features/{name}/ingest", json={"source": source, "mode": mode}
)
data = check_response(resp)
console.print(f"[green]✓[/green] Ingestion complete")
console.print(f" Records: {data.get('records', 0)}")
@@ -916,7 +980,12 @@ def datasets():
@datasets.command(name="create")
@click.argument("name")
@click.option("--from", "source", required=True, help="Source path or URI")
@click.option("--format", "fmt", type=click.Choice(["csv", "parquet", "tfrecord", "jsonl"]), help="Data format")
@click.option(
"--format",
"fmt",
type=click.Choice(["csv", "parquet", "tfrecord", "jsonl"]),
help="Data format",
)
@click.option("--split", help="Train/val/test split (e.g., 80:10:10)")
def datasets_create(name: str, source: str, fmt: str, split: str):
"""Create a versioned dataset."""
@@ -1005,11 +1074,15 @@ def automl():
@click.option("--dataset", "-d", required=True, help="Training dataset")
@click.option("--target", "-t", required=True, help="Target column")
@click.option(
"--task", type=click.Choice(["classification", "regression", "forecasting", "nlp", "vision"]), required=True
"--task",
type=click.Choice(["classification", "regression", "forecasting", "nlp", "vision"]),
required=True,
)
@click.option("--time-limit", default=3600, help="Time limit in seconds")
@click.option("--metric", "-m", help="Optimization metric")
def automl_create(name: str, dataset: str, target: str, task: str, time_limit: int, metric: str):
def automl_create(
name: str, dataset: str, target: str, task: str, time_limit: int, metric: str
):
"""Create an AutoML job.
\b
@@ -1018,7 +1091,13 @@ def automl_create(name: str, dataset: str, target: str, task: str, time_limit: i
hanzo ml automl create sales-forecast -d sales -t revenue --task forecasting
hanzo ml automl create sentiment -d reviews -t label --task nlp --time-limit 7200
"""
body = {"name": name, "dataset": dataset, "target": target, "task": task, "time_limit": time_limit}
body = {
"name": name,
"dataset": dataset,
"target": target,
"task": task,
"time_limit": time_limit,
}
if metric:
body["metric"] = metric
@@ -1050,7 +1129,9 @@ def automl_list():
for j in items:
j_status = j.get("status", "unknown")
style = {"running": "cyan", "completed": "green", "failed": "red"}.get(j_status, "white")
style = {"running": "cyan", "completed": "green", "failed": "red"}.get(
j_status, "white"
)
table.add_row(
j.get("name", ""),
j.get("task", "-"),
@@ -1072,7 +1153,9 @@ def automl_status(name: str):
data = check_response(resp)
status = data.get("status", "unknown")
status_style = {"running": "cyan", "completed": "green", "failed": "red"}.get(status, "yellow")
status_style = {"running": "cyan", "completed": "green", "failed": "red"}.get(
status, "yellow"
)
console.print(
Panel(
+25 -8
View File
@@ -26,7 +26,9 @@ def network_group():
@click.option("--consensus", is_flag=True, help="Require consensus")
@click.option("--timeout", "-t", type=int, default=300, help="Timeout in seconds")
@click.pass_context
async def dispatch(ctx, prompt: str, agents: int, model: str, mode: str, consensus: bool, timeout: int):
async def dispatch(
ctx, prompt: str, agents: int, model: str, mode: str, consensus: bool, timeout: int
):
"""Dispatch work to agent network."""
try:
from hanzo_network import NetworkDispatcher
@@ -389,9 +391,13 @@ def topology(ctx, json: bool):
)
if gpu_available:
console.print("[green]✓[/green] GPU/Accelerator detected and available for AI workloads")
console.print(
"[green]✓[/green] GPU/Accelerator detected and available for AI workloads"
)
if "APPLE M" in caps.chip.upper():
console.print(" [dim]Apple Silicon Neural Engine available for on-device AI[/dim]")
console.print(
" [dim]Apple Silicon Neural Engine available for on-device AI[/dim]"
)
else:
console.print("[yellow]⚠[/yellow] No GPU detected - using CPU only")
console.print(" To enable GPU support:")
@@ -541,7 +547,9 @@ def topology_add(
try:
import qrcode
except ImportError:
console.print("\n[yellow]QR code generation requires qrcode library[/yellow]")
console.print(
"\n[yellow]QR code generation requires qrcode library[/yellow]"
)
console.print("Install with: pip install qrcode[pil]")
return
@@ -692,7 +700,10 @@ def models(ctx, endpoint: str):
models_list = [
model
for model in all_models
if not any(keyword in model.get("id", "").lower() for keyword in embedding_keywords)
if not any(
keyword in model.get("id", "").lower()
for keyword in embedding_keywords
)
]
if models_list:
@@ -730,12 +741,16 @@ def models(ctx, endpoint: str):
table.add_row(str(i), model_id, tier, provider)
console.print(table)
console.print(f"\n[dim]Total LLMs: {len(models_list)} (embedding models filtered out)[/dim]")
console.print(
f"\n[dim]Total LLMs: {len(models_list)} (embedding models filtered out)[/dim]"
)
else:
console.print("[yellow]No models available on gateway[/yellow]")
else:
# Show static list of known models
console.print("[yellow]Could not fetch live models, showing known models:[/yellow]\n")
console.print(
"[yellow]Could not fetch live models, showing known models:[/yellow]\n"
)
_show_static_models()
except Exception as e:
console.print(f"[yellow]Error fetching models: {e}[/yellow]\n")
@@ -743,7 +758,9 @@ def models(ctx, endpoint: str):
console.print("\n[dim]To use a model:[/dim]")
console.print(" [cyan]hanzo dev --model llama3-8b-instruct[/cyan]")
console.print("\n[dim cyan]Free tier:[/dim cyan] [green]Most models available without login![/green]")
console.print(
"\n[dim cyan]Free tier:[/dim cyan] [green]Most models available without login![/green]"
)
console.print(
"[dim cyan]Premium models:[/dim cyan] [yellow]gpt-4, claude-3-opus, o1-preview[/yellow] - [cyan]hanzo auth login[/cyan]"
)
+18 -6
View File
@@ -31,7 +31,9 @@ async def start(ctx, name: str, port: int, models: tuple, device: str):
await start_node(ctx, name, port, list(models) if models else None, device)
async def start_node(ctx, name: str, port: int, models: Optional[List[str]] = None, device: str = "auto"):
async def start_node(
ctx, name: str, port: int, models: Optional[List[str]] = None, device: str = "auto"
):
"""Start a local node via hanzo-cluster."""
try:
from hanzo_cluster import HanzoCluster
@@ -138,7 +140,9 @@ async def status(ctx, name: str):
if workers := status.get("node_details", []):
console.print("\n[cyan]Workers:[/cyan]")
for worker in workers:
console.print(f" • {worker.get('name', 'unknown')} ({worker.get('state', 'unknown')})")
console.print(
f" • {worker.get('name', 'unknown')} ({worker.get('state', 'unknown')})"
)
if device := worker.get("device"):
console.print(f" Device: {device}")
else:
@@ -252,7 +256,9 @@ def worker_group():
default="auto",
help="Device to use",
)
@click.option("--port", "-p", type=int, help="Worker port (auto-assigned if not specified)")
@click.option(
"--port", "-p", type=int, help="Worker port (auto-assigned if not specified)"
)
@click.option("--blockchain", is_flag=True, help="Enable blockchain features")
@click.option("--network", is_flag=True, help="Enable network discovery")
@click.pass_context
@@ -425,7 +431,9 @@ async def worker_info(ctx, name: str):
if resources := info.get("resources"):
console.print("\n[cyan]Resources:[/cyan]")
console.print(f" CPU: {resources.get('cpu_percent', 'N/A')}%")
console.print(f" Memory: {resources.get('memory_used', 'N/A')} / {resources.get('memory_total', 'N/A')}")
console.print(
f" Memory: {resources.get('memory_used', 'N/A')} / {resources.get('memory_total', 'N/A')}"
)
if gpu := resources.get("gpu"):
console.print(
f" GPU: {gpu.get('name', 'N/A')} ({gpu.get('memory_used', 'N/A')} / {gpu.get('memory_total', 'N/A')})"
@@ -438,8 +446,12 @@ async def worker_info(ctx, name: str):
if network := info.get("network"):
console.print("\n[cyan]Network:[/cyan]")
console.print(f" Blockchain: {'enabled' if network.get('blockchain') else 'disabled'}")
console.print(f" Discovery: {'enabled' if network.get('discovery') else 'disabled'}")
console.print(
f" Blockchain: {'enabled' if network.get('blockchain') else 'disabled'}"
)
console.print(
f" Discovery: {'enabled' if network.get('discovery') else 'disabled'}"
)
if peers := network.get("peers"):
console.print(f" Peers: {len(peers)}")
+112 -29
View File
@@ -132,7 +132,9 @@ def metrics_query(promql: str, start: str, end: str, step: str):
@metrics.command(name="export")
@click.argument("promql")
@click.option("--format", "-f", "fmt", type=click.Choice(["json", "csv"]), default="json")
@click.option(
"--format", "-f", "fmt", type=click.Choice(["json", "csv"]), default="json"
)
@click.option("--output", "-o", help="Output file")
@click.option("--start", "-s", help="Start time")
@click.option("--end", "-e", help="End time")
@@ -149,10 +151,14 @@ def metrics_export(promql: str, fmt: str, output: str, start: str, end: str):
content = data.get("data", "")
if output:
with open(output, "w") as f:
f.write(content if isinstance(content, str) else json.dumps(content, indent=2))
f.write(
content if isinstance(content, str) else json.dumps(content, indent=2)
)
console.print(f"[green]✓[/green] Exported to {output}")
else:
console.print(content if isinstance(content, str) else json.dumps(content, indent=2))
console.print(
content if isinstance(content, str) else json.dumps(content, indent=2)
)
# ============================================================================
@@ -200,7 +206,12 @@ def logs_search(query: str, source: str, level: str, limit: int, start: str, end
for entry in entries:
lvl = entry.get("level", "info")
lvl_style = {"error": "red", "warn": "yellow", "info": "green", "debug": "dim"}.get(lvl, "white")
lvl_style = {
"error": "red",
"warn": "yellow",
"info": "green",
"debug": "dim",
}.get(lvl, "white")
table.add_row(
entry.get("timestamp", ""),
f"[{lvl_style}]{lvl}[/{lvl_style}]",
@@ -230,19 +241,32 @@ def logs_tail(source: str, filter: str, level: str):
try:
url = f"{SERVICE_URL}/v1/logs/tail"
from .base import get_api_key
api_key = get_api_key()
if not api_key:
raise click.ClickException("Not authenticated. Run 'hanzo login' first.")
with httpx.Client(timeout=None) as client:
with client.stream("GET", url, params=params, headers={"Authorization": f"Bearer {api_key}"}) as stream:
with client.stream(
"GET",
url,
params=params,
headers={"Authorization": f"Bearer {api_key}"},
) as stream:
for line in stream.iter_lines():
if line:
try:
entry = json.loads(line)
lvl = entry.get("level", "info")
lvl_style = {"error": "red", "warn": "yellow", "info": "green", "debug": "dim"}.get(lvl, "white")
console.print(f"[dim]{entry.get('timestamp', '')}[/dim] [{lvl_style}]{lvl}[/{lvl_style}] [cyan]{entry.get('source', '')}[/cyan] {entry.get('message', '')}")
lvl_style = {
"error": "red",
"warn": "yellow",
"info": "green",
"debug": "dim",
}.get(lvl, "white")
console.print(
f"[dim]{entry.get('timestamp', '')}[/dim] [{lvl_style}]{lvl}[/{lvl_style}] [cyan]{entry.get('source', '')}[/cyan] {entry.get('message', '')}"
)
except json.JSONDecodeError:
console.print(line)
except KeyboardInterrupt:
@@ -470,7 +494,11 @@ def alerts():
@alerts.command(name="list")
@click.option("--status", type=click.Choice(["firing", "pending", "inactive", "all"]), default="all")
@click.option(
"--status",
type=click.Choice(["firing", "pending", "inactive", "all"]),
default="all",
)
def alerts_list(status: str):
"""List alert rules."""
params: dict = {}
@@ -505,10 +533,19 @@ def alerts_list(status: str):
@alerts.command(name="create")
@click.option("--name", "-n", prompt=True, help="Alert name")
@click.option("--condition", "-c", required=True, help="PromQL condition")
@click.option("--severity", "-s", type=click.Choice(["critical", "warning", "info"]), default="warning")
@click.option(
"--severity",
"-s",
type=click.Choice(["critical", "warning", "info"]),
default="warning",
)
@click.option("--channel", help="Notification channel")
@click.option("--for-duration", "for_duration", default="5m", help="Duration before firing")
def alerts_create(name: str, condition: str, severity: str, channel: str, for_duration: str):
@click.option(
"--for-duration", "for_duration", default="5m", help="Duration before firing"
)
def alerts_create(
name: str, condition: str, severity: str, channel: str, for_duration: str
):
"""Create an alert rule."""
payload: dict = {
"name": name,
@@ -602,7 +639,9 @@ def prompts_create(name: str, template: str, model: str, config: str, label: str
payload["config"] = json.loads(config)
resp = _request("post", "/v1/prompts", json=payload)
data = check_response(resp)
console.print(f"[green]✓[/green] Prompt '{name}' created (v{data.get('version', '1')})")
console.print(
f"[green]✓[/green] Prompt '{name}' created (v{data.get('version', '1')})"
)
if model:
console.print(f" Model: {model}")
@@ -641,7 +680,11 @@ def prompts_get(name: str, version: int, label: str):
@click.option("--to", "to_label", required=True, help="Target label")
def prompts_promote(name: str, version: int, to_label: str):
"""Promote a prompt version to a label."""
resp = _request("post", f"/v1/prompts/{name}/promote", json={"version": version, "label": to_label})
resp = _request(
"post",
f"/v1/prompts/{name}/promote",
json={"version": version, "label": to_label},
)
check_response(resp)
console.print(f"[green]✓[/green] Promoted '{name}' v{version} to {to_label}")
@@ -757,7 +800,9 @@ def generations_show(generation_id: str):
@click.option("--by", type=click.Choice(["model", "prompt", "user"]), default="model")
def generations_stats(time_range: str, by: str):
"""Show generation statistics."""
resp = _request("get", "/v1/generations/stats", params={"range": time_range, "group_by": by})
resp = _request(
"get", "/v1/generations/stats", params={"range": time_range, "group_by": by}
)
data = check_response(resp)
console.print(f"[cyan]Generation Statistics (last {time_range}):[/cyan]")
@@ -798,7 +843,11 @@ def evals():
@evals.command(name="list")
@click.option("--status", type=click.Choice(["pending", "running", "completed", "all"]), default="all")
@click.option(
"--status",
type=click.Choice(["pending", "running", "completed", "all"]),
default="all",
)
def evals_list(status: str):
"""List evaluations."""
params: dict = {}
@@ -817,7 +866,12 @@ def evals_list(status: str):
for e in data.get("evals", []):
st = e.get("status", "pending")
st_style = {"completed": "green", "running": "cyan", "pending": "yellow", "failed": "red"}.get(st, "white")
st_style = {
"completed": "green",
"running": "cyan",
"pending": "yellow",
"failed": "red",
}.get(st, "white")
score = f"{e.get('score', 0):.2f}" if e.get("score") is not None else "-"
table.add_row(
e.get("name", ""),
@@ -864,7 +918,9 @@ def evals_run(name: str, parallel: int):
@evals.command(name="results")
@click.argument("name")
@click.option("--format", "-f", "fmt", type=click.Choice(["table", "json"]), default="table")
@click.option(
"--format", "-f", "fmt", type=click.Choice(["table", "json"]), default="table"
)
def evals_results(name: str, fmt: str):
"""Show evaluation results."""
resp = _request("get", f"/v1/evals/{name}/results")
@@ -918,7 +974,9 @@ def scores():
@scores.command(name="list")
@click.option("--trace", "-t", help="Filter by trace ID")
@click.option("--name", "-n", help="Filter by score name")
@click.option("--source", type=click.Choice(["api", "human", "model", "all"]), default="all")
@click.option(
"--source", type=click.Choice(["api", "human", "model", "all"]), default="all"
)
@click.option("--limit", default=50, help="Max results")
def scores_list(trace: str, name: str, source: str, limit: int):
"""List scores."""
@@ -958,7 +1016,9 @@ def scores_list(trace: str, name: str, source: str, limit: int):
@click.option("--name", "-n", required=True, help="Score name")
@click.option("--value", "-v", type=float, required=True, help="Score value (0-1)")
@click.option("--comment", "-c", help="Optional comment")
@click.option("--source", "-s", type=click.Choice(["api", "human", "model"]), default="api")
@click.option(
"--source", "-s", type=click.Choice(["api", "human", "model"]), default="api"
)
def scores_add(trace: str, name: str, value: float, comment: str, source: str):
"""Add a score to a trace."""
payload: dict = {"trace_id": trace, "name": name, "value": value, "source": source}
@@ -1083,27 +1143,42 @@ def datasets_add_item(dataset_name: str, input_data: str, expected: str, metadat
@datasets.command(name="import")
@click.argument("dataset_name")
@click.option("--file", "-f", "file_path", required=True, help="File to import (JSON/CSV)")
@click.option(
"--file", "-f", "file_path", required=True, help="File to import (JSON/CSV)"
)
def datasets_import(dataset_name: str, file_path: str):
"""Import items from file."""
with open(file_path) as f:
content = f.read()
if file_path.endswith(".csv"):
resp = _request("post", f"/v1/datasets/{dataset_name}/import", content=content, headers={"Content-Type": "text/csv"})
resp = _request(
"post",
f"/v1/datasets/{dataset_name}/import",
content=content,
headers={"Content-Type": "text/csv"},
)
else:
resp = _request("post", f"/v1/datasets/{dataset_name}/import", json=json.loads(content))
resp = _request(
"post", f"/v1/datasets/{dataset_name}/import", json=json.loads(content)
)
data = check_response(resp)
console.print(f"[green]✓[/green] Imported {data.get('count', '?')} items to '{dataset_name}'")
console.print(
f"[green]✓[/green] Imported {data.get('count', '?')} items to '{dataset_name}'"
)
@datasets.command(name="export")
@click.argument("dataset_name")
@click.option("--output", "-o", help="Output file")
@click.option("--format", "-f", "fmt", type=click.Choice(["json", "csv"]), default="json")
@click.option(
"--format", "-f", "fmt", type=click.Choice(["json", "csv"]), default="json"
)
def datasets_export(dataset_name: str, output: str, fmt: str):
"""Export dataset to file."""
resp = _request("get", f"/v1/datasets/{dataset_name}/export", params={"format": fmt})
resp = _request(
"get", f"/v1/datasets/{dataset_name}/export", params={"format": fmt}
)
data = check_response(resp)
out_file = output or f"{dataset_name}.{fmt}"
@@ -1256,10 +1331,14 @@ def llm_traces(user: str, name: str, session: str, limit: int):
@llm.command(name="costs")
@click.option("--range", "-r", "time_range", default="30d", help="Time range")
@click.option("--by", type=click.Choice(["model", "user", "prompt", "day"]), default="model")
@click.option(
"--by", type=click.Choice(["model", "user", "prompt", "day"]), default="model"
)
def llm_costs(time_range: str, by: str):
"""Show LLM cost analysis."""
resp = _request("get", "/v1/llm/costs", params={"range": time_range, "group_by": by})
resp = _request(
"get", "/v1/llm/costs", params={"range": time_range, "group_by": by}
)
data = check_response(resp)
console.print(f"[cyan]LLM Cost Analysis (last {time_range}):[/cyan]")
@@ -1288,10 +1367,14 @@ def llm_costs(time_range: str, by: str):
@llm.command(name="latency")
@click.option("--range", "-r", "time_range", default="24h", help="Time range")
@click.option("--by", type=click.Choice(["model", "prompt", "endpoint"]), default="model")
@click.option(
"--by", type=click.Choice(["model", "prompt", "endpoint"]), default="model"
)
def llm_latency(time_range: str, by: str):
"""Show LLM latency analysis."""
resp = _request("get", "/v1/llm/latency", params={"range": time_range, "group_by": by})
resp = _request(
"get", "/v1/llm/latency", params={"range": time_range, "group_by": by}
)
data = check_response(resp)
console.print(f"[cyan]LLM Latency Analysis (last {time_range}):[/cyan]")
+87 -34
View File
@@ -88,15 +88,23 @@ def edge_list():
@edge.command(name="deploy")
@click.option("--name", "-n", prompt=True, help="Deployment name")
@click.option("--dir", "-d", "source_dir", default=".", help="Directory to deploy")
@click.option("--regions", "-r", default="all", help="Target regions (comma-separated or 'all')")
@click.option(
"--regions", "-r", default="all", help="Target regions (comma-separated or 'all')"
)
def edge_deploy(name: str, source_dir: str, regions: str):
"""Deploy to edge locations."""
region_list = [r.strip() for r in regions.split(",")] if regions != "all" else ["all"]
resp = _request("post", "/v1/edge/deployments", json={
"name": name,
"source_dir": source_dir,
"regions": region_list,
})
region_list = (
[r.strip() for r in regions.split(",")] if regions != "all" else ["all"]
)
resp = _request(
"post",
"/v1/edge/deployments",
json={
"name": name,
"source_dir": source_dir,
"regions": region_list,
},
)
data = check_response(resp)
console.print(f"[green]✓[/green] Deployed '{name}' to edge")
@@ -133,7 +141,9 @@ def edge_delete(name: str):
@click.option("--period", "-p", default="24h", help="Time period")
def edge_stats(name: str, period: str):
"""Show edge deployment statistics."""
resp = _request("get", f"/v1/edge/deployments/{name}/stats", params={"period": period})
resp = _request(
"get", f"/v1/edge/deployments/{name}/stats", params={"period": period}
)
data = check_response(resp)
info = (
@@ -172,7 +182,9 @@ def hke_list():
for c in data.get("clusters", []):
st = c.get("status", "running")
st_style = {"running": "green", "provisioning": "yellow", "error": "red"}.get(st, "white")
st_style = {"running": "green", "provisioning": "yellow", "error": "red"}.get(
st, "white"
)
table.add_row(
c.get("name", ""),
c.get("version", ""),
@@ -191,16 +203,22 @@ def hke_list():
@click.option("--region", "-r", default="us-west-2", help="Region")
@click.option("--gpu", is_flag=True, help="Enable GPU node pool")
@click.option("--node-size", default="s-4vcpu-8gb", help="Node size")
def hke_create(name: str, version: str, nodes: int, region: str, gpu: bool, node_size: str):
def hke_create(
name: str, version: str, nodes: int, region: str, gpu: bool, node_size: str
):
"""Create a Kubernetes cluster."""
resp = _request("post", "/v1/hke/clusters", json={
"name": name,
"version": version,
"node_count": nodes,
"region": region,
"gpu": gpu,
"node_size": node_size,
})
resp = _request(
"post",
"/v1/hke/clusters",
json={
"name": name,
"version": version,
"node_count": nodes,
"region": region,
"gpu": gpu,
"node_size": node_size,
},
)
data = check_response(resp)
console.print(f"[green]✓[/green] Cluster '{name}' creation initiated")
@@ -218,7 +236,10 @@ def hke_delete(name: str, confirmed: bool):
"""Delete a Kubernetes cluster."""
if not confirmed:
from rich.prompt import Confirm
if not Confirm.ask(f"[red]Delete cluster '{name}'? This cannot be undone.[/red]"):
if not Confirm.ask(
f"[red]Delete cluster '{name}'? This cannot be undone.[/red]"
):
return
resp = _request("delete", f"/v1/hke/clusters/{name}")
check_response(resp)
@@ -240,6 +261,7 @@ def hke_kubeconfig(name: str, output: str):
console.print(f"[green]✓[/green] Kubeconfig saved to {output}")
else:
import os as _os
kube_path = _os.path.expanduser("~/.kube/config")
with open(kube_path, "w") as f:
f.write(kubeconfig)
@@ -251,7 +273,9 @@ def hke_kubeconfig(name: str, output: str):
@click.option("--nodes", "-n", required=True, type=int, help="Target nodes")
def hke_scale(name: str, nodes: int):
"""Scale cluster nodes."""
resp = _request("post", f"/v1/hke/clusters/{name}/scale", json={"node_count": nodes})
resp = _request(
"post", f"/v1/hke/clusters/{name}/scale", json={"node_count": nodes}
)
check_response(resp)
console.print(f"[green]✓[/green] Cluster '{name}' scaling to {nodes} nodes")
@@ -261,7 +285,9 @@ def hke_scale(name: str, nodes: int):
@click.option("--version", "-v", required=True, help="Target Kubernetes version")
def hke_upgrade(name: str, version: str):
"""Upgrade cluster Kubernetes version."""
resp = _request("post", f"/v1/hke/clusters/{name}/upgrade", json={"version": version})
resp = _request(
"post", f"/v1/hke/clusters/{name}/upgrade", json={"version": version}
)
check_response(resp)
console.print(f"[green]✓[/green] Cluster '{name}' upgrading to {version}")
@@ -306,6 +332,7 @@ def tunnel_share(target: str, name: str, auth: bool):
try:
tunnel_id = data.get("id", "")
import time
while True:
time.sleep(30)
_request("post", f"/v1/tunnels/{tunnel_id}/keepalive")
@@ -411,21 +438,31 @@ def dns_list(zone: str):
@dns.command(name="create")
@click.option("--zone", "-z", required=True, help="DNS zone")
@click.option("--name", "-n", required=True, help="Record name")
@click.option("--type", "-t", "record_type", required=True, help="Record type (A, CNAME, etc.)")
@click.option(
"--type", "-t", "record_type", required=True, help="Record type (A, CNAME, etc.)"
)
@click.option("--value", "-v", required=True, help="Record value")
@click.option("--ttl", default=300, help="TTL in seconds")
@click.option("--proxied", is_flag=True, help="Enable proxy")
def dns_create(zone: str, name: str, record_type: str, value: str, ttl: int, proxied: bool):
def dns_create(
zone: str, name: str, record_type: str, value: str, ttl: int, proxied: bool
):
"""Create a DNS record."""
resp = _request("post", f"/v1/dns/zones/{zone}/records", json={
"name": name,
"type": record_type,
"value": value,
"ttl": ttl,
"proxied": proxied,
})
resp = _request(
"post",
f"/v1/dns/zones/{zone}/records",
json={
"name": name,
"type": record_type,
"value": value,
"ttl": ttl,
"proxied": proxied,
},
)
check_response(resp)
console.print(f"[green]✓[/green] DNS record created: {name}.{zone} {record_type} {value}")
console.print(
f"[green]✓[/green] DNS record created: {name}.{zone} {record_type} {value}"
)
@dns.command(name="delete")
@@ -434,7 +471,11 @@ def dns_create(zone: str, name: str, record_type: str, value: str, ttl: int, pro
@click.option("--type", "-t", "record_type", required=True)
def dns_delete(zone: str, name: str, record_type: str):
"""Delete a DNS record."""
resp = _request("delete", f"/v1/dns/zones/{zone}/records", params={"name": name, "type": record_type})
resp = _request(
"delete",
f"/v1/dns/zones/{zone}/records",
params={"name": name, "type": record_type},
)
check_response(resp)
console.print(f"[green]✓[/green] DNS record deleted: {name}.{zone} {record_type}")
@@ -452,7 +493,13 @@ def guard():
@guard.command(name="enable")
@click.option("--project", "-p", help="Project ID")
@click.option("--mode", "-m", type=click.Choice(["block", "warn", "log"]), default="block", help="Enforcement mode")
@click.option(
"--mode",
"-m",
type=click.Choice(["block", "warn", "log"]),
default="block",
help="Enforcement mode",
)
def guard_enable(project: str, mode: str):
"""Enable LLM guard for project."""
payload: dict = {"enabled": True, "mode": mode}
@@ -505,7 +552,13 @@ def guard_status(project: str):
@guard.command(name="logs")
@click.option("--limit", "-n", default=50, help="Number of logs")
@click.option("--type", "-t", "log_type", type=click.Choice(["all", "blocked", "warned", "pii", "injection"]), default="all")
@click.option(
"--type",
"-t",
"log_type",
type=click.Choice(["all", "blocked", "warned", "pii", "injection"]),
default="all",
)
def guard_logs(limit: int, log_type: str):
"""View guard logs."""
params: dict = {"limit": limit}
+26 -8
View File
@@ -89,7 +89,9 @@ def topics_list(project: str):
console.print(table)
if not items:
console.print("[dim]No topics found. Create one with 'hanzo pubsub topics create'[/dim]")
console.print(
"[dim]No topics found. Create one with 'hanzo pubsub topics create'[/dim]"
)
@topics.command(name="create")
@@ -200,7 +202,9 @@ def subs_list(topic: str):
@click.option("--push-endpoint", help="Push endpoint URL")
@click.option("--ack-deadline", "-a", default=10, help="Ack deadline in seconds")
@click.option("--filter", "-f", help="Message filter expression")
def subs_create(name: str, topic: str, push_endpoint: str, ack_deadline: int, filter: str):
def subs_create(
name: str, topic: str, push_endpoint: str, ack_deadline: int, filter: str
):
"""Create a subscription."""
payload = {"name": name, "topic": topic, "ack_deadline": ack_deadline}
if push_endpoint:
@@ -308,7 +312,11 @@ def pull(subscription: str, max_messages: int, wait: bool, auto_ack: bool):
if auto_ack and messages:
ack_ids = [m.get("ack_id") for m in messages if m.get("ack_id")]
if ack_ids:
_request("post", f"/v1/subscriptions/{subscription}/ack", json={"ack_ids": ack_ids})
_request(
"post",
f"/v1/subscriptions/{subscription}/ack",
json={"ack_ids": ack_ids},
)
console.print(f"[dim]Auto-acknowledged {len(ack_ids)} message(s)[/dim]")
if not messages:
@@ -317,10 +325,14 @@ def pull(subscription: str, max_messages: int, wait: bool, auto_ack: bool):
@pubsub_group.command()
@click.argument("subscription")
@click.option("--ack-ids", "-a", multiple=True, required=True, help="Ack IDs to acknowledge")
@click.option(
"--ack-ids", "-a", multiple=True, required=True, help="Ack IDs to acknowledge"
)
def ack(subscription: str, ack_ids: tuple):
"""Acknowledge messages."""
resp = _request("post", f"/v1/subscriptions/{subscription}/ack", json={"ack_ids": list(ack_ids)})
resp = _request(
"post", f"/v1/subscriptions/{subscription}/ack", json={"ack_ids": list(ack_ids)}
)
check_response(resp)
console.print(f"[green]✓[/green] Acknowledged {len(ack_ids)} message(s)")
@@ -344,9 +356,13 @@ def seek(subscription: str, time: str, snapshot: str):
check_response(resp)
if time:
console.print(f"[green]✓[/green] Subscription '{subscription}' seeked to {time}")
console.print(
f"[green]✓[/green] Subscription '{subscription}' seeked to {time}"
)
else:
console.print(f"[green]✓[/green] Subscription '{subscription}' seeked to snapshot '{snapshot}'")
console.print(
f"[green]✓[/green] Subscription '{subscription}' seeked to snapshot '{snapshot}'"
)
# ============================================================================
@@ -391,7 +407,9 @@ def snapshots_list():
@click.option("--subscription", "-s", required=True, help="Subscription to snapshot")
def snapshots_create(name: str, subscription: str):
"""Create a snapshot of a subscription."""
resp = _request("post", "/v1/snapshots", json={"name": name, "subscription": subscription})
resp = _request(
"post", "/v1/snapshots", json={"name": name, "subscription": subscription}
)
check_response(resp)
console.print(f"[green]✓[/green] Snapshot '{name}' created from '{subscription}'")
+27 -9
View File
@@ -84,7 +84,9 @@ def queues_list(project: str):
console.print(table)
if not items:
console.print("[dim]No queues found. Create one with 'hanzo queues create'[/dim]")
console.print(
"[dim]No queues found. Create one with 'hanzo queues create'[/dim]"
)
@queues_group.command(name="create")
@@ -93,7 +95,9 @@ def queues_list(project: str):
@click.option("--rate-limit", "-r", help="Rate limit (e.g., '100/m')")
@click.option("--retry", default=3, help="Max retry attempts")
@click.option("--backoff", default="exponential", help="Backoff strategy")
def queues_create(name: str, concurrency: int, rate_limit: str, retry: int, backoff: str):
def queues_create(
name: str, concurrency: int, rate_limit: str, retry: int, backoff: str
):
"""Create a queue."""
payload = {
"name": name,
@@ -127,7 +131,9 @@ def queues_delete(name: str, force: bool):
if not Confirm.ask(f"[red]Delete queue '{name}'?[/red]"):
return
resp = _request("delete", f"/v1/queues/{name}", params={"force": str(force).lower()})
resp = _request(
"delete", f"/v1/queues/{name}", params={"force": str(force).lower()}
)
check_response(resp)
console.print(f"[green]✓[/green] Queue '{name}' deleted")
@@ -164,10 +170,14 @@ def queues_stats(name: str):
@click.argument("queue")
@click.option("--data", "-d", required=True, help="Job data (JSON)")
@click.option("--name", "-n", help="Job name")
@click.option("--priority", "-p", type=int, default=0, help="Job priority (higher = more urgent)")
@click.option(
"--priority", "-p", type=int, default=0, help="Job priority (higher = more urgent)"
)
@click.option("--delay", help="Delay before processing (e.g., '5m', '1h')")
@click.option("--attempts", "-a", default=3, help="Max attempts")
def queues_push(queue: str, data: str, name: str, priority: int, delay: str, attempts: int):
def queues_push(
queue: str, data: str, name: str, priority: int, delay: str, attempts: int
):
"""Push a job to a queue."""
payload = {"data": json.loads(data), "priority": priority, "max_attempts": attempts}
if name:
@@ -220,7 +230,9 @@ def queues_pop(queue: str, count: int, wait: bool):
@click.option("--count", "-n", default=5, help="Number of jobs to peek")
def queues_peek(queue: str, count: int):
"""Peek at jobs without removing them."""
resp = _request("get", f"/v1/queues/{queue}/jobs", params={"limit": count, "status": "pending"})
resp = _request(
"get", f"/v1/queues/{queue}/jobs", params={"limit": count, "status": "pending"}
)
data = check_response(resp)
jobs = data.get("jobs", data.get("items", []))
@@ -360,7 +372,9 @@ def dlq_retry(queue: str, job_id: str, all_jobs: bool):
resp = _request("post", f"/v1/queues/{queue}/dlq/retry", json=payload)
data = check_response(resp)
console.print(f"[green]✓[/green] {data.get('retried', 0)} DLQ job(s) moved back to queue")
console.print(
f"[green]✓[/green] {data.get('retried', 0)} DLQ job(s) moved back to queue"
)
@dlq.command(name="purge")
@@ -374,7 +388,9 @@ def dlq_purge(queue: str):
resp = _request("post", f"/v1/queues/{queue}/dlq/purge")
data = check_response(resp)
console.print(f"[green]✓[/green] Purged {data.get('purged', 0)} DLQ job(s) for '{queue}'")
console.print(
f"[green]✓[/green] Purged {data.get('purged', 0)} DLQ job(s) for '{queue}'"
)
@queues_group.command(name="drain")
@@ -390,7 +406,9 @@ def queues_drain(queue: str, delayed: bool):
payload = {"delayed": delayed}
resp = _request("post", f"/v1/queues/{queue}/drain", json=payload)
data = check_response(resp)
console.print(f"[green]✓[/green] Drained {data.get('drained', 0)} job(s) from '{queue}'")
console.print(
f"[green]✓[/green] Drained {data.get('drained', 0)} job(s) from '{queue}'"
)
if delayed:
console.print("[dim]Delayed jobs also removed[/dim]")
+9 -3
View File
@@ -105,14 +105,18 @@ def info(ctx):
if not providers:
console.print("\n[yellow]No LLM providers configured[/yellow]")
console.print("Set one of: OPENAI_API_KEY, ANTHROPIC_API_KEY, HANZO_API_KEY")
console.print(
"Set one of: OPENAI_API_KEY, ANTHROPIC_API_KEY, HANZO_API_KEY"
)
except ImportError:
console.print("[red]Error:[/red] hanzo-repl not installed")
@repl_group.command()
@click.option("--interface", type=click.Choice(["all", "ipython", "tui", "voice"]), default="all")
@click.option(
"--interface", type=click.Choice(["all", "ipython", "tui", "voice"]), default="all"
)
@click.pass_context
def install_extras(ctx, interface: str):
"""Install optional REPL components."""
@@ -165,7 +169,9 @@ def exec(ctx, command: tuple, model: str):
except ImportError as e:
console.print(f"[red]Import Error:[/red] {e}")
console.print("[yellow]Note:[/yellow] hanzo-repl may not be installed correctly")
console.print(
"[yellow]Note:[/yellow] hanzo-repl may not be installed correctly"
)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
+20 -6
View File
@@ -40,7 +40,9 @@ def start_router(ctx, port: int, config: Optional[str], detach: bool):
if not router_dir:
console.print("[red]Error:[/red] Hanzo router not found")
console.print("\nPlease clone the router:")
console.print(" git clone https://github.com/hanzoai/router.git ~/work/hanzo/router")
console.print(
" git clone https://github.com/hanzoai/router.git ~/work/hanzo/router"
)
return
console.print(f"[green]✓[/green] Found router at {router_dir}")
@@ -89,7 +91,9 @@ def start_router(ctx, port: int, config: Optional[str], detach: bool):
stderr=subprocess.DEVNULL,
start_new_session=True,
)
console.print(f"[green]✓[/green] Router started in background (PID: {process.pid})")
console.print(
f"[green]✓[/green] Router started in background (PID: {process.pid})"
)
console.print(f"Check status: curl http://localhost:{port}/health")
else:
# Run in foreground
@@ -112,8 +116,14 @@ def stop_router(port: int):
for proc in psutil.process_iter(["pid", "cmdline"]):
try:
cmdline = proc.info["cmdline"]
if cmdline and "proxy_server" in " ".join(cmdline) and str(port) in " ".join(cmdline):
console.print(f"[yellow]Stopping router (PID: {proc.info['pid']})[/yellow]")
if (
cmdline
and "proxy_server" in " ".join(cmdline)
and str(port) in " ".join(cmdline)
):
console.print(
f"[yellow]Stopping router (PID: {proc.info['pid']})[/yellow]"
)
proc.send_signal(signal.SIGTERM)
proc.wait(timeout=5)
found = True
@@ -138,7 +148,9 @@ def router_status(port: int):
# Try to get models
try:
models_response = httpx.get(f"http://localhost:{port}/models", timeout=2.0)
models_response = httpx.get(
f"http://localhost:{port}/models", timeout=2.0
)
if models_response.status_code == 200:
data = models_response.json()
if "data" in data:
@@ -146,7 +158,9 @@ def router_status(port: int):
except Exception:
pass
else:
console.print(f"[yellow]Router responding but unhealthy (status: {response.status_code})[/yellow]")
console.print(
f"[yellow]Router responding but unhealthy (status: {response.status_code})[/yellow]"
)
except httpx.ConnectError:
console.print(f"[red]Router not running on port {port}[/red]")
console.print("\nStart with: hanzo router start")
+35 -10
View File
@@ -91,7 +91,9 @@ def _find_container(client, base_url: str, name: str):
@click.argument("name", required=False)
@click.option("--image", "-i", help="Container image")
@click.option("--source", "-s", help="Git repo URL (source build)")
@click.option("--env", "-e", "env_name", help="Override environment (uses context by default)")
@click.option(
"--env", "-e", "env_name", help="Override environment (uses context by default)"
)
@click.option("--replicas", "-r", default=1, help="Number of replicas")
@click.option("--port", "-p", default=8080, help="Service port")
@click.option("--cpu", default="0.5", help="CPU cores")
@@ -184,7 +186,11 @@ def run_service(name, image, source, env_name, replicas, port, cpu, memory, wait
# Trigger build if source
if source and not existing:
cid = (result or {}).get("_id") or (result or {}).get("iid") or (result or {}).get("id", "")
cid = (
(result or {}).get("_id")
or (result or {}).get("iid")
or (result or {}).get("id", "")
)
if cid:
console.print("[cyan]Triggering build...[/cyan]")
client.post(f"{base_url}/{cid}/trigger")
@@ -197,7 +203,10 @@ def run_service(name, image, source, env_name, replicas, port, cpu, memory, wait
cid = (result or {}).get("_id") or existing_id
if cid:
data = client.get(f"{base_url}/{cid}")
if data and data.get("status", {}).get("availableReplicas", 0) >= replicas:
if (
data
and data.get("status", {}).get("availableReplicas", 0) >= replicas
):
console.print("[green]✓[/green] Deployment ready")
return
time.sleep(2)
@@ -290,7 +299,9 @@ def run_status(name):
replicas = container.get("deploymentConfig", {}).get("desiredReplicas", "?")
avail = status_info.get("availableReplicas", "?")
state = status_info.get("conditions", [{}])
state_str = "[green]● Running[/green]" if avail else "[yellow]○ Pending[/yellow]"
state_str = (
"[green]● Running[/green]" if avail else "[yellow]○ Pending[/yellow]"
)
img = ""
if container.get("repoOrRegistry") == "registry":
@@ -324,7 +335,9 @@ def run_status(name):
if not containers:
console.print("[dim]No services deployed in current context.[/dim]")
console.print("[dim]Deploy with: hanzo run service NAME --image IMAGE[/dim]")
console.print(
"[dim]Deploy with: hanzo run service NAME --image IMAGE[/dim]"
)
return
table = Table(title="Services", box=box.ROUNDED)
@@ -392,7 +405,9 @@ def run_logs(name, follow, tail, since):
console.print("[dim]No logs available.[/dim]")
if follow:
console.print("[dim]Follow mode is not yet supported via API. Use 'hanzo k8s logs' for streaming.[/dim]")
console.print(
"[dim]Follow mode is not yet supported via API. Use 'hanzo k8s logs' for streaming.[/dim]"
)
# ============================================================================
@@ -523,7 +538,11 @@ def run_rollback(name, version):
console.print("[yellow]No pipeline history found.[/yellow]")
return
runs = pipelines if isinstance(pipelines, list) else pipelines.get("runs", pipelines.get("data", []))
runs = (
pipelines
if isinstance(pipelines, list)
else pipelines.get("runs", pipelines.get("data", []))
)
if version:
# Find specific run
@@ -562,7 +581,9 @@ def run_traffic(name, version):
hanzo run traffic my-api -v v2:100 # Full cutover
"""
if not version:
console.print("[yellow]Traffic splitting requires PaaS ingress configuration.[/yellow]")
console.print(
"[yellow]Traffic splitting requires PaaS ingress configuration.[/yellow]"
)
console.print("[dim]Specify version weights: -v v1:90 -v v2:10[/dim]")
return
@@ -573,7 +594,9 @@ def run_traffic(name, version):
if len(parts) == 2:
console.print(f" {parts[0]}: {parts[1]}%")
console.print("[yellow]Traffic splitting requires ingress controller support.[/yellow]")
console.print(
"[yellow]Traffic splitting requires ingress controller support.[/yellow]"
)
console.print("[dim]Configure via 'hanzo k8s ingress' or your service mesh.[/dim]")
@@ -595,7 +618,9 @@ def run_delete(name, force):
if not force:
from rich.prompt import Confirm
if not Confirm.ask(f"[red]Delete service '{name}'? This cannot be undone.[/red]"):
if not Confirm.ask(
f"[red]Delete service '{name}'? This cannot be undone.[/red]"
):
return
result = client.delete(f"{base_url}/{cid}")
+55 -13
View File
@@ -60,9 +60,16 @@ def search_group():
@search_group.command(name="create")
@click.argument("name")
@click.option("--mode", "-m", type=click.Choice(["lexical", "vector", "hybrid"]), default="hybrid")
@click.option(
"--mode", "-m", type=click.Choice(["lexical", "vector", "hybrid"]), default="hybrid"
)
@click.option("--region", "-r", help="Region")
@click.option("--tier", "-t", type=click.Choice(["free", "standard", "dedicated"]), default="standard")
@click.option(
"--tier",
"-t",
type=click.Choice(["free", "standard", "dedicated"]),
default="standard",
)
def search_create(name: str, mode: str, region: str, tier: str):
"""Create a search engine."""
payload = {"name": name, "mode": mode, "tier": tier}
@@ -107,7 +114,9 @@ def search_list():
console.print(table)
if not engines:
console.print("[dim]No search engines found. Create one with 'hanzo search create'[/dim]")
console.print(
"[dim]No search engines found. Create one with 'hanzo search create'[/dim]"
)
@search_group.command(name="describe")
@@ -166,11 +175,18 @@ def index():
@index.command(name="create")
@click.argument("name")
@click.option("--engine", "-e", default="default", help="Search engine")
@click.option("--mode", "-m", type=click.Choice(["lexical", "vector", "hybrid"]), help="Override engine mode")
@click.option(
"--mode",
"-m",
type=click.Choice(["lexical", "vector", "hybrid"]),
help="Override engine mode",
)
@click.option("--mapping", help="Mapping JSON or file")
@click.option("--shards", "-s", default=1, help="Number of shards")
@click.option("--replicas", "-r", default=1, help="Number of replicas")
def index_create(name: str, engine: str, mode: str, mapping: str, shards: int, replicas: int):
def index_create(
name: str, engine: str, mode: str, mapping: str, shards: int, replicas: int
):
"""Create a search index."""
payload = {"name": name, "shards": shards, "replicas": replicas}
if mode:
@@ -208,7 +224,9 @@ def index_list(engine: str):
for idx in items:
health = idx.get("health", "unknown")
style = "green" if health == "green" else "yellow" if health == "yellow" else "red"
style = (
"green" if health == "green" else "yellow" if health == "yellow" else "red"
)
table.add_row(
idx.get("name", ""),
idx.get("mode", "-"),
@@ -277,7 +295,9 @@ def index_mapping(name: str, engine: str, get_mapping: bool, set_mapping: str):
else:
mapping_data = json.loads(set_mapping)
resp = _request("put", f"/v1/engines/{engine}/indexes/{name}/mapping", json=mapping_data)
resp = _request(
"put", f"/v1/engines/{engine}/indexes/{name}/mapping", json=mapping_data
)
check_response(resp)
console.print(f"[green]✓[/green] Mapping updated for '{name}'")
else:
@@ -295,11 +315,18 @@ def index_mapping(name: str, engine: str, get_mapping: bool, set_mapping: str):
@search_group.command(name="ingest")
@click.option("--index", "-i", required=True, help="Target index")
@click.option("--from", "source", required=True, help="Source: file, s3://, storage://")
@click.option("--format", "fmt", type=click.Choice(["jsonl", "json", "csv", "parquet"]), default="jsonl")
@click.option(
"--format",
"fmt",
type=click.Choice(["jsonl", "json", "csv", "parquet"]),
default="jsonl",
)
@click.option("--pipeline", "-p", help="Ingest pipeline to apply")
@click.option("--batch-size", "-b", default=1000, help="Batch size")
@click.option("--engine", "-e", default="default")
def search_ingest(index: str, source: str, fmt: str, pipeline: str, batch_size: int, engine: str):
def search_ingest(
index: str, source: str, fmt: str, pipeline: str, batch_size: int, engine: str
):
"""Ingest documents into an index.
\b
@@ -318,7 +345,9 @@ def search_ingest(index: str, source: str, fmt: str, pipeline: str, batch_size:
if pipeline:
payload["pipeline"] = pipeline
resp = _request("post", f"/v1/engines/{engine}/indexes/{index}/ingest", json=payload)
resp = _request(
"post", f"/v1/engines/{engine}/indexes/{index}/ingest", json=payload
)
data = check_response(resp)
console.print(f"[green]✓[/green] Ingestion complete")
@@ -337,7 +366,16 @@ def search_ingest(index: str, source: str, fmt: str, pipeline: str, batch_size:
@click.option("--vector-field", help="Field for vector search")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
@click.option("--engine", "-e", default="default")
def search_query(index: str, q: str, filter: str, topk: int, mode: str, vector_field: str, as_json: bool, engine: str):
def search_query(
index: str,
q: str,
filter: str,
topk: int,
mode: str,
vector_field: str,
as_json: bool,
engine: str,
):
"""Search documents.
\b
@@ -380,7 +418,9 @@ def search_query(index: str, q: str, filter: str, topk: int, mode: str, vector_f
console.print(table)
total = data.get("total", len(hits))
console.print(f"[dim]{total} total hit(s), showing top {min(topk, len(hits))}[/dim]")
console.print(
f"[dim]{total} total hit(s), showing top {min(topk, len(hits))}[/dim]"
)
@search_group.command(name="reindex")
@@ -389,7 +429,9 @@ def search_query(index: str, q: str, filter: str, topk: int, mode: str, vector_f
@click.option("--query", "-q", help="Filter query")
@click.option("--pipeline", "-p", help="Transform pipeline")
@click.option("--engine", "-e", default="default")
def search_reindex(source_idx: str, dest_idx: str, query: str, pipeline: str, engine: str):
def search_reindex(
source_idx: str, dest_idx: str, query: str, pipeline: str, engine: str
):
"""Reindex documents."""
console.print(f"[cyan]Reindexing from '{source_idx}' to '{dest_idx}'...[/cyan]")
+9 -3
View File
@@ -157,7 +157,9 @@ def secrets_list(env: str, prefix: str):
console.print(table)
if not items:
console.print("[dim]No secrets found. Create one with 'hanzo secrets set'[/dim]")
console.print(
"[dim]No secrets found. Create one with 'hanzo secrets set'[/dim]"
)
@secrets_group.command(name="unset")
@@ -260,10 +262,14 @@ def secrets_rollback(name: str, version: str, env: str):
@secrets_group.command(name="grant")
@click.argument("name")
@click.option("--to", "-t", required=True, help="Service or user to grant access")
@click.option("--role", "-r", default="read", type=click.Choice(["read", "write", "admin"]))
@click.option(
"--role", "-r", default="read", type=click.Choice(["read", "write", "admin"])
)
def secrets_grant(name: str, to: str, role: str):
"""Grant access to a secret."""
resp = _request("post", f"/v1/secrets/{name}/access", json={"principal": to, "role": role})
resp = _request(
"post", f"/v1/secrets/{name}/access", json={"principal": to, "role": role}
)
check_response(resp)
console.print(f"[green]✓[/green] Granted {role} access to '{name}' for {to}")
+71 -26
View File
@@ -34,7 +34,9 @@ def _get_s3_client():
import boto3
from botocore.config import Config
except ImportError:
console.print("[red]boto3 not installed.[/red] Run: pip install 'hanzo[storage]'")
console.print(
"[red]boto3 not installed.[/red] Run: pip install 'hanzo[storage]'"
)
raise SystemExit(1)
# Try to get credentials from env or hanzo auth
@@ -171,14 +173,21 @@ def buckets_list(json_out: bool):
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,
))
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]")
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)
@@ -278,12 +287,14 @@ def storage_ls(path: str, recursive: bool, human: bool, json_out: bool):
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",
})
items.append(
{
"key": o["Key"],
"size": o["Size"],
"modified": o["LastModified"].isoformat(),
"type": "file",
}
)
click.echo(json.dumps(items, indent=2))
return
@@ -327,8 +338,14 @@ def storage_cp(source: str, dest: str, recursive: bool):
hanzo storage cp mybucket/a.txt mybucket/b.txt Server-side copy
"""
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 ".")
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://"):
@@ -347,13 +364,17 @@ def storage_cp(source: str, dest: str, recursive: bool):
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task = progress.add_task(f"Uploading {len(files)} files...", total=len(files))
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]")
console.print(
f"[green]Uploaded {len(files)} files to {bucket}/{key}[/green]"
)
else:
if not key or key.endswith("/"):
key = key + local_path.name
@@ -381,7 +402,9 @@ def storage_cp(source: str, dest: str, recursive: bool):
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task = progress.add_task(f"Downloading {len(objects)} files...", total=len(objects))
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:
@@ -397,7 +420,9 @@ def storage_cp(source: str, dest: str, recursive: bool):
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]")
console.print(
f"[green]Downloaded s3://{bucket}/{key} -> {local_path}[/green]"
)
elif is_s3_src and is_s3_dst:
# Server-side copy
@@ -408,9 +433,13 @@ def storage_cp(source: str, dest: str, recursive: bool):
Bucket=dst_bucket,
Key=dst_key,
)
console.print(f"[green]Copied s3://{src_bucket}/{src_key} -> s3://{dst_bucket}/{dst_key}[/green]")
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]")
console.print(
"[red]At least one path must be an S3 path (bucket/key or s3://bucket/key).[/red]"
)
raise SystemExit(1)
@@ -436,7 +465,9 @@ def storage_mv(source: str, dest: str):
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]")
console.print(
f"[green]Moved s3://{src_bucket}/{src_key} -> s3://{dst_bucket}/{dst_key}[/green]"
)
@storage_group.command(name="rm")
@@ -466,7 +497,9 @@ def storage_rm(path: str, recursive: bool, yes: bool):
return
if not yes:
click.confirm(f"Delete {len(objects)} object(s) under '{path}'?", abort=True)
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):
@@ -486,7 +519,9 @@ def storage_rm(path: str, recursive: bool, yes: bool):
@storage_group.command(name="sync")
@click.argument("source")
@click.argument("dest")
@click.option("--delete", "delete_extra", 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_extra: bool, dry_run: bool):
"""Sync directories with storage.
@@ -539,7 +574,9 @@ def storage_sync(source: str, dest: str, delete_extra: bool, dry_run: bool):
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]")
console.print(
f"[dim]Would upload {len(to_upload)}, delete {len(to_delete)}[/dim]"
)
return
for rel in to_upload:
@@ -596,7 +633,9 @@ def storage_sync(source: str, dest: str, delete_extra: bool, dry_run: bool):
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]")
console.print(
f"[dim]Would download {len(to_download)}, delete {len(to_delete)}[/dim]"
)
return
for rel in to_download:
@@ -615,7 +654,13 @@ def storage_sync(source: str, dest: str, delete_extra: bool, dry_run: bool):
@storage_group.command(name="presign")
@click.argument("path")
@click.option("--expires", "-e", default=3600, type=int, help="Expiration in seconds (default: 3600)")
@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: int, method: str):
"""Generate a presigned URL for an object.
+42 -8
View File
@@ -70,7 +70,15 @@ def tasks_group():
@click.option("--env", "-e", multiple=True, help="Environment variables")
@click.option("--timeout", "-t", default="1h", help="Task timeout")
@click.option("--retries", "-r", default=3, help="Max retries on failure")
def tasks_create(name: str, image: str, cmd: str, function: str, env: tuple, timeout: str, retries: int):
def tasks_create(
name: str,
image: str,
cmd: str,
function: str,
env: tuple,
timeout: str,
retries: int,
):
"""Create a task definition.
\b
@@ -109,7 +117,9 @@ def tasks_create(name: str, image: str, cmd: str, function: str, env: tuple, tim
@tasks_group.command(name="list")
@click.option("--status", "-s", type=click.Choice(["active", "disabled", "all"]), default="all")
@click.option(
"--status", "-s", type=click.Choice(["active", "disabled", "all"]), default="all"
)
def tasks_list(status: str):
"""List all tasks."""
params = {}
@@ -131,7 +141,11 @@ def tasks_list(status: str):
for t in items:
t_status = t.get("status", "active")
style = "green" if t_status == "active" else "yellow"
task_type = "image" if t.get("image") else "function" if t.get("function") else "command"
task_type = (
"image"
if t.get("image")
else "function" if t.get("function") else "command"
)
table.add_row(
t.get("name", ""),
task_type,
@@ -350,7 +364,12 @@ def runs():
@runs.command(name="list")
@click.argument("task", required=False)
@click.option("--status", "-s", type=click.Choice(["running", "success", "failed", "all"]), default="all")
@click.option(
"--status",
"-s",
type=click.Choice(["running", "success", "failed", "all"]),
default="all",
)
@click.option("--limit", "-n", default=20, help="Max results")
def runs_list(task: str, status: str, limit: int):
"""List task runs."""
@@ -414,7 +433,9 @@ def runs_logs(run_id: str, follow: bool, tail: int):
ts = str(line.get("timestamp", ""))[:19]
level = line.get("level", "info")
msg = line.get("message", "")
style = "red" if level == "error" else "yellow" if level == "warn" else "dim"
style = (
"red" if level == "error" else "yellow" if level == "warn" else "dim"
)
console.print(f"[dim]{ts}[/dim] [{style}]{level}[/{style}] {msg}")
else:
console.print(str(line))
@@ -463,7 +484,12 @@ def triggers():
type=click.Choice(["event", "queue", "topic", "http", "schedule"]),
help="Trigger type",
)
@click.option("--source", "-s", required=True, help="Trigger source (event name, queue name, etc.)")
@click.option(
"--source",
"-s",
required=True,
help="Trigger source (event name, queue name, etc.)",
)
@click.option("--filter", "-f", help="Event filter expression")
def triggers_add(task: str, trigger_type: str, source: str, filter: str):
"""Add a trigger to a task.
@@ -544,7 +570,11 @@ def triggers_rm(task: str, source: str, remove_all: bool):
@click.option("--source", "-s", help="Specific trigger")
def triggers_pause(task: str, source: str):
"""Pause triggers."""
path = f"/v1/tasks/{task}/triggers/{source}/pause" if source else f"/v1/tasks/{task}/triggers/pause"
path = (
f"/v1/tasks/{task}/triggers/{source}/pause"
if source
else f"/v1/tasks/{task}/triggers/pause"
)
resp = _request("post", path)
check_response(resp)
console.print(f"[green]✓[/green] Paused trigger(s) for '{task}'")
@@ -555,7 +585,11 @@ def triggers_pause(task: str, source: str):
@click.option("--source", "-s", help="Specific trigger")
def triggers_resume(task: str, source: str):
"""Resume triggers."""
path = f"/v1/tasks/{task}/triggers/{source}/resume" if source else f"/v1/tasks/{task}/triggers/resume"
path = (
f"/v1/tasks/{task}/triggers/{source}/resume"
if source
else f"/v1/tasks/{task}/triggers/resume"
)
resp = _request("post", path)
check_response(resp)
console.print(f"[green]✓[/green] Resumed trigger(s) for '{task}'")
+6 -2
View File
@@ -30,7 +30,9 @@ async def list_tools(ctx, category: str, installed: bool):
with console.status("Loading tools..."):
try:
tools = await registry.list_tools(category=category, installed_only=installed)
tools = await registry.list_tools(
category=category, installed_only=installed
)
except Exception as e:
console.print(f"[red]Failed to load tools: {e}[/red]")
return
@@ -86,7 +88,9 @@ async def install(ctx, tool_name: str, version: str):
try:
result = await registry.install_tool(name=tool_name, version=version)
console.print(f"[green]✓[/green] Installed {tool_name} v{result['version']}")
console.print(
f"[green]✓[/green] Installed {tool_name} v{result['version']}"
)
if deps := result.get("dependencies_installed"):
console.print(f" Dependencies: {', '.join(deps)}")
+82 -21
View File
@@ -57,8 +57,18 @@ def vector_group():
@vector_group.command(name="create")
@click.argument("name")
@click.option("--region", "-r", help="Region")
@click.option("--tier", "-t", type=click.Choice(["free", "standard", "dedicated"]), default="standard")
@click.option("--metric", "-m", type=click.Choice(["cosine", "euclidean", "dotproduct"]), default="cosine")
@click.option(
"--tier",
"-t",
type=click.Choice(["free", "standard", "dedicated"]),
default="standard",
)
@click.option(
"--metric",
"-m",
type=click.Choice(["cosine", "euclidean", "dotproduct"]),
default="cosine",
)
def vector_create(name: str, region: str, tier: str, metric: str):
"""Create a vector database."""
payload = {"name": name, "tier": tier, "default_metric": metric}
@@ -103,7 +113,9 @@ def vector_list():
console.print(table)
if not dbs:
console.print("[dim]No vector databases found. Create one with 'hanzo vector create'[/dim]")
console.print(
"[dim]No vector databases found. Create one with 'hanzo vector create'[/dim]"
)
@vector_group.command(name="describe")
@@ -163,9 +175,15 @@ def collections():
@click.argument("name")
@click.option("--db", "-d", default="default", help="Database name")
@click.option("--dimension", "-dim", type=int, required=True, help="Vector dimension")
@click.option("--metric", "-m", type=click.Choice(["cosine", "euclidean", "dotproduct"]))
@click.option("--index-type", "-i", type=click.Choice(["hnsw", "ivf", "flat"]), default="hnsw")
def collections_create(name: str, db: str, dimension: int, metric: str, index_type: str):
@click.option(
"--metric", "-m", type=click.Choice(["cosine", "euclidean", "dotproduct"])
)
@click.option(
"--index-type", "-i", type=click.Choice(["hnsw", "ivf", "flat"]), default="hnsw"
)
def collections_create(
name: str, db: str, dimension: int, metric: str, index_type: str
):
"""Create a vector collection.
\b
@@ -267,9 +285,19 @@ def collections_delete(name: str, db: str, force: bool):
@click.option("--from", "source", required=True, help="Source: file, jsonl, parquet")
@click.option("--id-field", help="Field to use as ID")
@click.option("--vector-field", default="embedding", help="Field containing vectors")
@click.option("--embed", help="Embed text using model (e.g., openai:text-embedding-3-small)")
@click.option(
"--embed", help="Embed text using model (e.g., openai:text-embedding-3-small)"
)
@click.option("--batch-size", "-b", default=100, help="Batch size")
def vector_upsert(collection: str, db: str, source: str, id_field: str, vector_field: str, embed: str, batch_size: int):
def vector_upsert(
collection: str,
db: str,
source: str,
id_field: str,
vector_field: str,
embed: str,
batch_size: int,
):
"""Upsert vectors into a collection.
\b
@@ -305,11 +333,15 @@ def vector_upsert(collection: str, db: str, source: str, id_field: str, vector_f
if embed:
payload["embed_model"] = embed
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/upsert", json=payload)
resp = _request(
"post", f"/v1/databases/{db}/collections/{collection}/upsert", json=payload
)
result = check_response(resp)
total += result.get("upserted", len(batch))
errors += result.get("errors", 0)
console.print(f" Batch {i // batch_size + 1}: {result.get('upserted', len(batch))} upserted")
console.print(
f" Batch {i // batch_size + 1}: {result.get('upserted', len(batch))} upserted"
)
console.print(f"[green]✓[/green] Upsert complete")
console.print(f" Vectors: {total}")
@@ -361,7 +393,9 @@ def vector_query(
if embed:
payload["embed_model"] = embed
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/query", json=payload)
resp = _request(
"post", f"/v1/databases/{db}/collections/{collection}/query", json=payload
)
data = check_response(resp)
matches = data.get("matches", data.get("results", []))
@@ -395,7 +429,9 @@ def vector_fetch(ids: tuple, collection: str, db: str, include_vectors: bool):
"""Fetch vectors by ID."""
payload = {"ids": list(ids), "include_vectors": include_vectors}
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/fetch", json=payload)
resp = _request(
"post", f"/v1/databases/{db}/collections/{collection}/fetch", json=payload
)
data = check_response(resp)
vectors = data.get("vectors", [])
@@ -409,7 +445,9 @@ def vector_fetch(ids: tuple, collection: str, db: str, include_vectors: bool):
)
if include_vectors and v.get("values"):
dims = v["values"][:5]
console.print(f" Vector: [{', '.join(f'{d:.4f}' for d in dims)}, ...] ({len(v['values'])} dims)")
console.print(
f" Vector: [{', '.join(f'{d:.4f}' for d in dims)}, ...] ({len(v['values'])} dims)"
)
if not vectors:
console.print("[dim]No vectors found[/dim]")
@@ -422,7 +460,9 @@ def vector_fetch(ids: tuple, collection: str, db: str, include_vectors: bool):
@click.option("--filter", "-f", help="Delete by filter")
@click.option("--all", "delete_all", is_flag=True, help="Delete all vectors")
@click.option("--force", is_flag=True)
def vector_delete_vectors(collection: str, db: str, ids: str, filter: str, delete_all: bool, force: bool):
def vector_delete_vectors(
collection: str, db: str, ids: str, filter: str, delete_all: bool, force: bool
):
"""Delete vectors from a collection."""
if delete_all and not force:
from rich.prompt import Confirm
@@ -438,9 +478,13 @@ def vector_delete_vectors(collection: str, db: str, ids: str, filter: str, delet
if delete_all:
payload["delete_all"] = True
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/delete", json=payload)
resp = _request(
"post", f"/v1/databases/{db}/collections/{collection}/delete", json=payload
)
data = check_response(resp)
console.print(f"[green]✓[/green] Deleted {data.get('deleted', 0)} vector(s) from '{collection}'")
console.print(
f"[green]✓[/green] Deleted {data.get('deleted', 0)} vector(s) from '{collection}'"
)
# ============================================================================
@@ -488,13 +532,24 @@ def indexes_list(collection: str, db: str):
@click.argument("collection")
@click.option("--db", "-d", default="default")
@click.option("--name", "-n", help="Index name")
@click.option("--type", "idx_type", type=click.Choice(["hnsw", "ivf", "flat"]), default="hnsw")
@click.option("--metric", "-m", type=click.Choice(["cosine", "euclidean", "dotproduct"]))
@click.option(
"--type", "idx_type", type=click.Choice(["hnsw", "ivf", "flat"]), default="hnsw"
)
@click.option(
"--metric", "-m", type=click.Choice(["cosine", "euclidean", "dotproduct"])
)
@click.option("--hnsw-m", type=int, default=16, help="HNSW M parameter")
@click.option("--hnsw-ef", type=int, default=200, help="HNSW efConstruction")
@click.option("--ivf-nlist", type=int, default=100, help="IVF number of lists")
def indexes_create(
collection: str, db: str, name: str, idx_type: str, metric: str, hnsw_m: int, hnsw_ef: int, ivf_nlist: int
collection: str,
db: str,
name: str,
idx_type: str,
metric: str,
hnsw_m: int,
hnsw_ef: int,
ivf_nlist: int,
):
"""Create a vector index.
@@ -514,7 +569,9 @@ def indexes_create(
elif idx_type == "ivf":
payload["parameters"] = {"nlist": ivf_nlist}
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/indexes", json=payload)
resp = _request(
"post", f"/v1/databases/{db}/collections/{collection}/indexes", json=payload
)
data = check_response(resp)
console.print(f"[green]✓[/green] Index created on '{collection}'")
@@ -537,7 +594,11 @@ def indexes_rebuild(collection: str, db: str, name: str):
payload["index_name"] = name
console.print(f"[cyan]Rebuilding index for '{collection}'...[/cyan]")
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/indexes/rebuild", json=payload)
resp = _request(
"post",
f"/v1/databases/{db}/collections/{collection}/indexes/rebuild",
json=payload,
)
data = check_response(resp)
console.print(f"[green]✓[/green] Index rebuilt ({data.get('duration', '-')})")
+215 -69
View File
@@ -221,7 +221,9 @@ class HanzoDevOrchestrator:
grpo_dir = self.workspace_dir / "grpo"
grpo_dir.mkdir(exist_ok=True)
self.experience_manager = ExperienceManager(str(grpo_dir / "experience_library.json"))
self.experience_manager = ExperienceManager(
str(grpo_dir / "experience_library.json")
)
# Initialize with DeepSeek if API key available
deepseek_key = os.getenv("DEEPSEEK_API_KEY")
@@ -252,7 +254,9 @@ class HanzoDevOrchestrator:
for path in possible_paths:
expanded = Path(path).expanduser()
if expanded.exists() or (path == "claude" and os.system(f"which {path} >/dev/null 2>&1") == 0):
if expanded.exists() or (
path == "claude" and os.system(f"which {path} >/dev/null 2>&1") == 0
):
return str(expanded) if expanded.exists() else path
raise RuntimeError("Claude Code not found. Please specify path.")
@@ -339,7 +343,9 @@ class HanzoDevOrchestrator:
else:
return "Execute standard workflow with monitoring"
def _assess_risks(self, problem: str, alternatives: List[str], context: Dict[str, Any]) -> List[str]:
def _assess_risks(
self, problem: str, alternatives: List[str], context: Dict[str, Any]
) -> List[str]:
"""Assess risks of different approaches."""
risks = []
@@ -363,7 +369,9 @@ class HanzoDevOrchestrator:
) -> str:
"""Synthesize a decision from analysis."""
if len(risks) > 2:
return "Proceed cautiously with incremental approach and rollback capability"
return (
"Proceed cautiously with incremental approach and rollback capability"
)
elif alternatives:
return f"Execute primary approach: {alternatives[0]}"
else:
@@ -408,7 +416,9 @@ class HanzoDevOrchestrator:
if resume:
checkpoint_file = self._get_latest_checkpoint()
if checkpoint_file:
console.print(f"[green]Resuming from checkpoint: {checkpoint_file.name}[/green]")
console.print(
f"[green]Resuming from checkpoint: {checkpoint_file.name}[/green]"
)
# Prepare command
cmd = [self.claude_code_path]
@@ -526,8 +536,12 @@ class HanzoDevOrchestrator:
"timestamp": datetime.now().isoformat(),
"agent_state": self.agent_state.value,
"runtime_health": asdict(self.runtime_health),
"current_context": (asdict(self.current_context) if self.current_context else None),
"thinking_history": [asdict(t) for t in self.thinking_history[-10:]], # Last 10
"current_context": (
asdict(self.current_context) if self.current_context else None
),
"thinking_history": [
asdict(t) for t in self.thinking_history[-10:]
], # Last 10
}
with open(checkpoint_file, "w") as f:
@@ -545,7 +559,9 @@ class HanzoDevOrchestrator:
self.agent_state = AgentState(data["agent_state"])
# Restore other state as needed
console.print(f"[green]✓ Restored from checkpoint: {checkpoint_file.name}[/green]")
console.print(
f"[green]✓ Restored from checkpoint: {checkpoint_file.name}[/green]"
)
return True
except Exception as e:
console.print(f"[red]Failed to restore checkpoint: {e}[/red]")
@@ -561,7 +577,9 @@ class HanzoDevOrchestrator:
healthy = await self.health_check()
if not healthy:
console.print(f"[yellow]Health check failed. State: {self.runtime_health.state.value}[/yellow]")
console.print(
f"[yellow]Health check failed. State: {self.runtime_health.state.value}[/yellow]"
)
# Use System 2 thinking to decide what to do
thinking_result = await self.think(
@@ -570,7 +588,9 @@ class HanzoDevOrchestrator:
)
console.print(f"[cyan]Decision: {thinking_result.decision}[/cyan]")
console.print(f"[cyan]Confidence: {thinking_result.confidence:.2f}[/cyan]")
console.print(
f"[cyan]Confidence: {thinking_result.confidence:.2f}[/cyan]"
)
# Execute decision
if thinking_result.confidence > 0.6:
@@ -725,10 +745,14 @@ class HanzoDevOrchestrator:
experiences_after = len(self.experience_manager.experiences)
# Save updated library
self.experience_manager.save(str(self.workspace_dir / "grpo" / "experience_library.json"))
self.experience_manager.save(
str(self.workspace_dir / "grpo" / "experience_library.json")
)
learned_count = experiences_after - experiences_before
console.print(f"[green]✓ Learned {learned_count} new experiences (total: {experiences_after})[/green]")
console.print(
f"[green]✓ Learned {learned_count} new experiences (total: {experiences_after})[/green]"
)
return learned_count
@@ -831,7 +855,9 @@ class HanzoDevREPL:
handler = FallbackHandler()
if not handler.fallback_order:
console.print("[yellow]⚠️ No API keys detected[/yellow]")
console.print("[dim]Set OPENAI_API_KEY or ANTHROPIC_API_KEY to enable AI[/dim]")
console.print(
"[dim]Set OPENAI_API_KEY or ANTHROPIC_API_KEY to enable AI[/dim]"
)
console.print()
else:
primary = handler.fallback_order[0][1]
@@ -877,9 +903,13 @@ class HanzoDevREPL:
# Handle memory/context commands
from .memory_manager import handle_memory_command
handled = handle_memory_command(user_input, self.memory_manager, console)
handled = handle_memory_command(
user_input, self.memory_manager, console
)
if not handled:
console.print("[yellow]Unknown memory command. Use #memory help[/yellow]")
console.print(
"[yellow]Unknown memory command. Use #memory help[/yellow]"
)
else:
# Natural chat - send directly to AI agents
@@ -922,13 +952,17 @@ class HanzoDevREPL:
table.add_row("Agent State", self.orchestrator.agent_state.value)
table.add_row("Runtime State", self.orchestrator.runtime_health.state.value)
table.add_row("Last Response", str(self.orchestrator.runtime_health.last_response))
table.add_row(
"Last Response", str(self.orchestrator.runtime_health.last_response)
)
table.add_row(
"Response Time",
f"{self.orchestrator.runtime_health.response_time_ms:.2f}ms",
)
table.add_row("Error Count", str(self.orchestrator.runtime_health.error_count))
table.add_row("Restart Count", str(self.orchestrator.runtime_health.restart_count))
table.add_row(
"Restart Count", str(self.orchestrator.runtime_health.restart_count)
)
console.print(table)
@@ -975,7 +1009,9 @@ class HanzoDevREPL:
"""Restore from checkpoint."""
if not args:
# Show available checkpoints
checkpoints = list(self.orchestrator.checkpoint_dir.glob("checkpoint_*.json"))
checkpoints = list(
self.orchestrator.checkpoint_dir.glob("checkpoint_*.json")
)
if checkpoints:
console.print("[cyan]Available checkpoints:[/cyan]")
for cp in checkpoints:
@@ -1052,7 +1088,10 @@ Examples:
enhanced_message = message
# Try smart fallback if no specific model configured
if not hasattr(self.orchestrator, "orchestrator_model") or self.orchestrator.orchestrator_model == "auto":
if (
not hasattr(self.orchestrator, "orchestrator_model")
or self.orchestrator.orchestrator_model == "auto"
):
# Use streaming if available
from .streaming import stream_with_fallback
@@ -1064,7 +1103,9 @@ Examples:
# Response already displayed by streaming handler
return
else:
console.print("[red]No AI options available. Please configure API keys or install tools.[/red]")
console.print(
"[red]No AI options available. Please configure API keys or install tools.[/red]"
)
return
# For codex and other CLI tools, go straight to direct API chat
@@ -1305,14 +1346,26 @@ Examples:
# No API keys available
console.print("[red]No AI API keys configured![/red]")
console.print("[yellow]Try these options that don't need your API key:[/yellow]")
console.print(
"[yellow]Try these options that don't need your API key:[/yellow]"
)
console.print("\n[bold]CLI Tools (use existing tools):[/bold]")
console.print(" • hanzo dev --orchestrator codex # OpenAI CLI (if installed)")
console.print(" • hanzo dev --orchestrator claude # Claude Desktop (if installed)")
console.print(" • hanzo dev --orchestrator gemini # Gemini CLI (if installed)")
console.print(" • hanzo dev --orchestrator hanzo-ide # Hanzo IDE from ~/work/hanzo/ide")
console.print(
" • hanzo dev --orchestrator codex # OpenAI CLI (if installed)"
)
console.print(
" • hanzo dev --orchestrator claude # Claude Desktop (if installed)"
)
console.print(
" • hanzo dev --orchestrator gemini # Gemini CLI (if installed)"
)
console.print(
" • hanzo dev --orchestrator hanzo-ide # Hanzo IDE from ~/work/hanzo/ide"
)
console.print("\n[bold]Free APIs (rate limited):[/bold]")
console.print(" • hanzo dev --orchestrator codestral # Free Mistral Codestral")
console.print(
" • hanzo dev --orchestrator codestral # Free Mistral Codestral"
)
console.print(" • hanzo dev --orchestrator starcoder # Free StarCoder")
console.print("\n[bold]Local Models (unlimited):[/bold]")
console.print(" • hanzo dev --orchestrator local:llama3.2 # Via Ollama")
@@ -1355,10 +1408,16 @@ Examples:
if response.status_code == 200:
data = response.json()
if data.get("choices"):
console.print(f"[cyan]Codestral:[/cyan] {data['choices'][0]['message']['content']}")
console.print(
f"[cyan]Codestral:[/cyan] {data['choices'][0]['message']['content']}"
)
else:
console.print("[yellow]Free tier limit reached. Try local models instead:[/yellow]")
console.print(" • Install Ollama: curl -fsSL https://ollama.com/install.sh | sh")
console.print(
"[yellow]Free tier limit reached. Try local models instead:[/yellow]"
)
console.print(
" • Install Ollama: curl -fsSL https://ollama.com/install.sh | sh"
)
console.print(" • Run: ollama pull codellama")
console.print(" • Use: hanzo dev --orchestrator local:codellama")
@@ -1395,9 +1454,13 @@ Examples:
if response.status_code == 200:
data = response.json()
if isinstance(data, list) and data:
console.print(f"[cyan]StarCoder:[/cyan] {data[0].get('generated_text', '')}")
console.print(
f"[cyan]StarCoder:[/cyan] {data[0].get('generated_text', '')}"
)
else:
console.print("[yellow]API limit reached. Install local models:[/yellow]")
console.print(
"[yellow]API limit reached. Install local models:[/yellow]"
)
console.print(" • brew install ollama")
console.print(" • ollama pull starcoder2")
console.print(" • hanzo dev --orchestrator local:starcoder2")
@@ -1434,7 +1497,9 @@ Examples:
message,
]
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
stdout, stderr = process.communicate(timeout=30)
@@ -1466,7 +1531,11 @@ Examples:
claude_path = None
for path in claude_paths:
if os.path.exists(path) or subprocess.run(["which", path], capture_output=True).returncode == 0:
if (
os.path.exists(path)
or subprocess.run(["which", path], capture_output=True).returncode
== 0
):
claude_path = path
break
@@ -1495,7 +1564,9 @@ Examples:
"""
subprocess.run(["osascript", "-e", script])
console.print("[cyan]Sent to Claude Desktop. Check the app for response.[/cyan]")
console.print(
"[cyan]Sent to Claude Desktop. Check the app for response.[/cyan]"
)
else:
# Try direct CLI invocation
process = subprocess.Popen(
@@ -1534,7 +1605,9 @@ Examples:
# Use gemini CLI
cmd = ["gemini", "chat", message]
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
stdout, stderr = process.communicate(timeout=30)
@@ -1562,7 +1635,9 @@ Examples:
console.print("[red]Hanzo Dev IDE not found![/red]")
console.print("[yellow]Expected location: ~/work/hanzo/ide[/yellow]")
console.print("To set up:")
console.print(" • git clone https://github.com/hanzoai/ide ~/work/hanzo/ide")
console.print(
" • git clone https://github.com/hanzoai/ide ~/work/hanzo/ide"
)
console.print(" • cd ~/work/hanzo/ide && npm install")
return
@@ -1598,7 +1673,9 @@ Examples:
cmd = [cli_path, "chat", message]
cwd = None
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=cwd)
process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=cwd
)
stdout, stderr = process.communicate(timeout=30)
@@ -1645,7 +1722,9 @@ Examples:
if response.status_code == 200:
data = response.json()
if data.get("message"):
console.print(f"[cyan]{model_name}:[/cyan] {data['message']['content']}")
console.print(
f"[cyan]{model_name}:[/cyan] {data['message']['content']}"
)
return
except Exception:
@@ -1676,7 +1755,9 @@ Examples:
if response.status_code == 200:
data = response.json()
if data.get("choices"):
console.print(f"[cyan]{model_name}:[/cyan] {data['choices'][0]['message']['content']}")
console.print(
f"[cyan]{model_name}:[/cyan] {data['choices'][0]['message']['content']}"
)
return
except Exception:
@@ -1754,7 +1835,9 @@ async def run_dev_orchestrator(**kwargs):
# Check if we should use network mode
# For now, disable network mode since hanzo-network isn't available
if False and use_network and NETWORK_AVAILABLE:
console_obj.print(f"[cyan]Mode: Network Orchestration with hanzo-network[/cyan]")
console_obj.print(
f"[cyan]Mode: Network Orchestration with hanzo-network[/cyan]"
)
console_obj.print(f"Orchestrator: {orchestrator_model}")
console_obj.print(f"Workers: {instances} agents")
console_obj.print(f"Critics: {max(1, instances // 2)} agents")
@@ -1860,19 +1943,25 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
# Check if we can use hanzo-network
if not NETWORK_AVAILABLE:
self.console.print("[yellow]Warning: hanzo-network not available, falling back to basic mode[/yellow]")
self.console.print(
"[yellow]Warning: hanzo-network not available, falling back to basic mode[/yellow]"
)
async def initialize(self):
"""Initialize the agent network with orchestrator and workers."""
if not NETWORK_AVAILABLE:
self.console.print("[red]Cannot initialize network mode without hanzo-network[/red]")
self.console.print(
"[red]Cannot initialize network mode without hanzo-network[/red]"
)
return False
# Start hanzo net if requested for local orchestration
if self.use_hanzo_net or self.orchestrator_model.startswith("local:"):
await self._start_hanzo_net()
self.console.print(f"[cyan]Initializing agent network with {self.orchestrator_model} orchestrator...[/cyan]")
self.console.print(
f"[cyan]Initializing agent network with {self.orchestrator_model} orchestrator...[/cyan]"
)
# Create orchestrator agent (GPT-5, local, or other model)
self.orchestrator_agent = await self._create_orchestrator_agent()
@@ -1889,7 +1978,9 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
for i in range(num_local_workers):
local_worker = await self._create_local_worker_agent(i)
self.worker_agents.append(local_worker)
self.console.print(f"[green]Added {num_local_workers} local workers for cost optimization[/green]")
self.console.print(
f"[green]Added {num_local_workers} local workers for cost optimization[/green]"
)
# Create critic agents for System 2 thinking
if self.enable_guardrails:
@@ -1912,15 +2003,21 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
self.agent_network = create_network(
agents=all_agents,
router=router,
default_agent=(self.orchestrator_agent.name if self.orchestrator_agent else None),
default_agent=(
self.orchestrator_agent.name if self.orchestrator_agent else None
),
)
self.console.print(f"[green]✓ Agent network initialized with {len(all_agents)} agents[/green]")
self.console.print(
f"[green]✓ Agent network initialized with {len(all_agents)} agents[/green]"
)
return True
async def _start_hanzo_net(self):
"""Start hanzo net for local AI orchestration."""
self.console.print("[cyan]Starting hanzo/net for local AI orchestration...[/cyan]")
self.console.print(
"[cyan]Starting hanzo/net for local AI orchestration...[/cyan]"
)
# Check if hanzo net is already running
import socket
@@ -1930,7 +2027,9 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
sock.close()
if result == 0:
self.console.print(f"[yellow]hanzo/net already running on port {self.hanzo_net_port}[/yellow]")
self.console.print(
f"[yellow]hanzo/net already running on port {self.hanzo_net_port}[/yellow]"
)
return
# Start hanzo net
@@ -1991,7 +2090,9 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
tools=[],
)
self.console.print(f"[green]✓ Created local {model_name} orchestrator via hanzo/net[/green]")
self.console.print(
f"[green]✓ Created local {model_name} orchestrator via hanzo/net[/green]"
)
return orchestrator
# Parse model string to get provider and model
@@ -2047,7 +2148,9 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
tools=[], # Orchestrator tools will be added
)
self.console.print(f"[green]✓ Created {self.orchestrator_model} orchestrator[/green]")
self.console.print(
f"[green]✓ Created {self.orchestrator_model} orchestrator[/green]"
)
return orchestrator
def _get_orchestrator_system_prompt(self) -> str:
@@ -2180,7 +2283,10 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
"count",
"find",
]
if any(keyword in prompt_lower for keyword in simple_keywords) and self.local_workers:
if (
any(keyword in prompt_lower for keyword in simple_keywords)
and self.local_workers
):
return self.local_workers[0].name
# Complex implementation → API workers (Claude)
@@ -2192,7 +2298,10 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
"design",
"architect",
]
if any(keyword in prompt_lower for keyword in complex_keywords) and self.api_workers:
if (
any(keyword in prompt_lower for keyword in complex_keywords)
and self.api_workers
):
return self.api_workers[0].name
# Review tasks → Critics
@@ -2203,7 +2312,10 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
"improve",
"validate code",
]
if any(keyword in prompt_lower for keyword in review_keywords) and self.critics:
if (
any(keyword in prompt_lower for keyword in review_keywords)
and self.critics
):
return self.critics[0].name
# Strategic decisions → Orchestrator
@@ -2224,12 +2336,20 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
return self.local_workers[0].name
# Fall back to API workers for complex tasks
return self.api_workers[0].name if self.api_workers else self.orchestrator.name
return (
self.api_workers[0].name
if self.api_workers
else self.orchestrator.name
)
# Create the cost-optimized router
router = CostOptimizedRouter(self.orchestrator_agent, self.worker_agents, self.critic_agents)
router = CostOptimizedRouter(
self.orchestrator_agent, self.worker_agents, self.critic_agents
)
self.console.print("[green]✓ Created cost-optimized router (local models preferred)[/green]")
self.console.print(
"[green]✓ Created cost-optimized router (local models preferred)[/green]"
)
return router
async def _create_intelligent_router(self) -> Router:
@@ -2264,7 +2384,9 @@ class NetworkOrchestrator(HanzoDevOrchestrator):
return router
async def execute_with_network(self, task: str, context: Optional[Dict] = None) -> Dict:
async def execute_with_network(
self, task: str, context: Optional[Dict] = None
) -> Dict:
"""Execute a task using the agent network.
Args:
@@ -2402,7 +2524,9 @@ class MultiClaudeOrchestrator(HanzoDevOrchestrator):
config = await self._create_instance_config(i, role)
self.instance_configs.append(config)
self.console.print(f" [{i + 1}/{self.num_instances}] {role} instance configured")
self.console.print(
f" [{i + 1}/{self.num_instances}] {role} instance configured"
)
# If networking enabled, configure MCP connections between instances
if self.enable_networking:
@@ -2464,7 +2588,9 @@ class MultiClaudeOrchestrator(HanzoDevOrchestrator):
async def _setup_mcp_networking(self):
"""Set up MCP networking between Claude instances."""
self.console.print("[cyan]Setting up MCP networking between instances...[/cyan]")
self.console.print(
"[cyan]Setting up MCP networking between instances...[/cyan]"
)
# Each instance gets MCP servers for all other instances
for i, config in enumerate(self.instance_configs):
@@ -2607,10 +2733,14 @@ class MultiClaudeOrchestrator(HanzoDevOrchestrator):
# Validate improvement didn't degrade quality
if await self._validate_improvement(result, improved):
self.console.print("[green]✓ Solution improved with System 2 feedback[/green]")
self.console.print(
"[green]✓ Solution improved with System 2 feedback[/green]"
)
return improved
else:
self.console.print("[yellow]⚠ Keeping original solution (improvement validation failed)[/yellow]")
self.console.print(
"[yellow]⚠ Keeping original solution (improvement validation failed)[/yellow]"
)
return result
@@ -2687,7 +2817,9 @@ class MultiClaudeOrchestrator(HanzoDevOrchestrator):
try:
import subprocess
result = subprocess.run(["gemini", "chat", prompt], capture_output=True, text=True, timeout=30)
result = subprocess.run(
["gemini", "chat", prompt], capture_output=True, text=True, timeout=30
)
if result.returncode == 0 and result.stdout:
return {"output": result.stdout.strip(), "success": True}
except Exception as e:
@@ -2751,7 +2883,9 @@ class MultiClaudeOrchestrator(HanzoDevOrchestrator):
logger.error(f"OpenAI API error: {e}")
# Try Anthropic
anthropic_key = os.environ.get("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_API_KEY")
anthropic_key = os.environ.get("ANTHROPIC_API_KEY") or os.getenv(
"ANTHROPIC_API_KEY"
)
if anthropic_key:
try:
from anthropic import AsyncAnthropic
@@ -2770,7 +2904,9 @@ class MultiClaudeOrchestrator(HanzoDevOrchestrator):
# Try fallback handler as last resort
from .fallback_handler import smart_chat
response = await smart_chat(prompt, console=None) # No console to avoid duplicate messages
response = await smart_chat(
prompt, console=None
) # No console to avoid duplicate messages
if response:
return {"output": response, "success": True}
@@ -2818,15 +2954,25 @@ async def main():
"""Main entry point for hanzo-dev."""
import argparse
parser = argparse.ArgumentParser(description="Hanzo Dev - System 2 Meta-AI Orchestrator")
parser.add_argument("--workspace", default="~/.hanzo/dev", help="Workspace directory")
parser = argparse.ArgumentParser(
description="Hanzo Dev - System 2 Meta-AI Orchestrator"
)
parser.add_argument(
"--workspace", default="~/.hanzo/dev", help="Workspace directory"
)
parser.add_argument("--claude-path", help="Path to Claude Code executable")
parser.add_argument("--monitor", action="store_true", help="Start in monitor mode")
parser.add_argument("--repl", action="store_true", help="Start REPL interface")
parser.add_argument("--instances", type=int, default=2, help="Number of Claude instances")
parser.add_argument(
"--instances", type=int, default=2, help="Number of Claude instances"
)
parser.add_argument("--no-mcp", action="store_true", help="Disable MCP tools")
parser.add_argument("--no-network", action="store_true", help="Disable instance networking")
parser.add_argument("--no-guardrails", action="store_true", help="Disable guardrails")
parser.add_argument(
"--no-network", action="store_true", help="Disable instance networking"
)
parser.add_argument(
"--no-guardrails", action="store_true", help="Disable guardrails"
)
args = parser.parse_args()
+21 -7
View File
@@ -23,7 +23,9 @@ class FallbackHandler:
"deepseek_api": bool(os.getenv("DEEPSEEK_API_KEY")), # Added DeepSeek
"openai_api": bool(os.getenv("OPENAI_API_KEY")),
"anthropic_api": bool(os.getenv("ANTHROPIC_API_KEY")),
"google_api": bool(os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")),
"google_api": bool(
os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
),
"openai_cli": shutil.which("openai") is not None,
"claude_cli": shutil.which("claude") is not None,
"gemini_cli": shutil.which("gemini") is not None,
@@ -101,7 +103,9 @@ class FallbackHandler:
suggestions = []
if not self.available_options["deepseek_api"]:
suggestions.append("• Set DEEPSEEK_API_KEY for cost-effective DeepSeek access ($0.14/M tokens)")
suggestions.append(
"• Set DEEPSEEK_API_KEY for cost-effective DeepSeek access ($0.14/M tokens)"
)
if not self.available_options["openai_api"]:
suggestions.append("• Set OPENAI_API_KEY for GPT-4/GPT-5 access")
@@ -110,22 +114,30 @@ class FallbackHandler:
suggestions.append("• Set ANTHROPIC_API_KEY for Claude access")
if not self.available_options["ollama"]:
suggestions.append("• Install Ollama: curl -fsSL https://ollama.com/install.sh | sh")
suggestions.append(
"• Install Ollama: curl -fsSL https://ollama.com/install.sh | sh"
)
suggestions.append(" Then run: ollama pull llama3.2")
if not self.available_options["openai_cli"]:
suggestions.append("• Install OpenAI CLI: pip install openai-cli")
if not self.available_options["claude_cli"]:
suggestions.append("• Install Claude Desktop from https://claude.ai/download")
suggestions.append(
"• Install Claude Desktop from https://claude.ai/download"
)
return "\n".join(suggestions) if suggestions else "All AI options are available!"
return (
"\n".join(suggestions) if suggestions else "All AI options are available!"
)
def print_status(self, console):
"""Print the current status of available AI options."""
from rich.table import Table
table = Table(title="Available AI Options", show_header=True, header_style="bold magenta")
table = Table(
title="Available AI Options", show_header=True, header_style="bold magenta"
)
table.add_column("Option", style="cyan", width=20)
table.add_column("Status", width=10)
table.add_column("Model", width=20)
@@ -152,7 +164,9 @@ class FallbackHandler:
console.print(table)
if self.fallback_order:
console.print(f"\n[green]Primary option: {self.fallback_order[0][1]}[/green]")
console.print(
f"\n[green]Primary option: {self.fallback_order[0][1]}[/green]"
)
if len(self.fallback_order) > 1:
fallbacks = ", ".join([opt[1] for opt in self.fallback_order[1:]])
console.print(f"[yellow]Fallback options: {fallbacks}[/yellow]")
+51 -13
View File
@@ -46,9 +46,27 @@ from .pubsub import Message, PubSubClient, PubSubConfig, Subscription
from .queues import Job, JobStatus, QueueStats, QueuesClient, QueuesConfig
from .search import SearchHit, SearchClient, SearchConfig, SearchResult
from .vector import ScoredPoint, VectorPoint, VectorClient, VectorConfig
from .storage import ObjectInfo, PresignedUrl, UploadResult, StorageClient, StorageConfig
from .functions import FunctionSpec, InvokeResult, FunctionStatus, FunctionsClient, FunctionsConfig
from .documentdb import Document, DeleteResult, UpdateResult, DocumentDBClient, DocumentDBConfig
from .storage import (
ObjectInfo,
PresignedUrl,
UploadResult,
StorageClient,
StorageConfig,
)
from .functions import (
FunctionSpec,
InvokeResult,
FunctionStatus,
FunctionsClient,
FunctionsConfig,
)
from .documentdb import (
Document,
DeleteResult,
UpdateResult,
DocumentDBClient,
DocumentDBConfig,
)
__all__ = [
# Main class
@@ -183,70 +201,90 @@ class HanzoInfra:
def vector(self) -> VectorClient:
"""Get vector client (Qdrant)."""
if self._vector is None:
raise RuntimeError("Vector client not initialized. Initialize with services=['vector']")
raise RuntimeError(
"Vector client not initialized. Initialize with services=['vector']"
)
return self._vector
@property
def kv(self) -> KVClient:
"""Get key-value client (Redis/Valkey)."""
if self._kv is None:
raise RuntimeError("KV client not initialized. Initialize with services=['kv']")
raise RuntimeError(
"KV client not initialized. Initialize with services=['kv']"
)
return self._kv
@property
def documentdb(self) -> DocumentDBClient:
"""Get document database client (MongoDB)."""
if self._documentdb is None:
raise RuntimeError("DocumentDB client not initialized. Initialize with services=['documentdb']")
raise RuntimeError(
"DocumentDB client not initialized. Initialize with services=['documentdb']"
)
return self._documentdb
@property
def storage(self) -> StorageClient:
"""Get object storage client (S3/MinIO)."""
if self._storage is None:
raise RuntimeError("Storage client not initialized. Initialize with services=['storage']")
raise RuntimeError(
"Storage client not initialized. Initialize with services=['storage']"
)
return self._storage
@property
def search(self) -> SearchClient:
"""Get search client (Meilisearch)."""
if self._search is None:
raise RuntimeError("Search client not initialized. Initialize with services=['search']")
raise RuntimeError(
"Search client not initialized. Initialize with services=['search']"
)
return self._search
@property
def pubsub(self) -> PubSubClient:
"""Get pub/sub client (NATS)."""
if self._pubsub is None:
raise RuntimeError("PubSub client not initialized. Initialize with services=['pubsub']")
raise RuntimeError(
"PubSub client not initialized. Initialize with services=['pubsub']"
)
return self._pubsub
@property
def tasks(self) -> TasksClient:
"""Get tasks/workflow client (Temporal)."""
if self._tasks is None:
raise RuntimeError("Tasks client not initialized. Initialize with services=['tasks']")
raise RuntimeError(
"Tasks client not initialized. Initialize with services=['tasks']"
)
return self._tasks
@property
def queues(self) -> QueuesClient:
"""Get work queue client."""
if self._queues is None:
raise RuntimeError("Queues client not initialized. Initialize with services=['queues']")
raise RuntimeError(
"Queues client not initialized. Initialize with services=['queues']"
)
return self._queues
@property
def cron(self) -> CronClient:
"""Get cron scheduler client."""
if self._cron is None:
raise RuntimeError("Cron client not initialized. Initialize with services=['cron']")
raise RuntimeError(
"Cron client not initialized. Initialize with services=['cron']"
)
return self._cron
@property
def functions(self) -> FunctionsClient:
"""Get serverless functions client (Nuclio)."""
if self._functions is None:
raise RuntimeError("Functions client not initialized. Initialize with services=['functions']")
raise RuntimeError(
"Functions client not initialized. Initialize with services=['functions']"
)
return self._functions
@classmethod
+24 -6
View File
@@ -100,8 +100,16 @@ class CronJob:
args=data.get("args", {}),
enabled=data.get("enabled", True),
timezone=data.get("timezone", "UTC"),
last_run=datetime.fromisoformat(data["last_run"]) if data.get("last_run") else None,
next_run=datetime.fromisoformat(data["next_run"]) if data.get("next_run") else None,
last_run=(
datetime.fromisoformat(data["last_run"])
if data.get("last_run")
else None
),
next_run=(
datetime.fromisoformat(data["next_run"])
if data.get("next_run")
else None
),
run_count=data.get("run_count", 0),
error_count=data.get("error_count", 0),
last_error=data.get("last_error"),
@@ -185,7 +193,9 @@ class CronClient:
try:
import redis.asyncio as redis
except ImportError as e:
raise ImportError("redis is required for CronClient. Install with: pip install redis") from e
raise ImportError(
"redis is required for CronClient. Install with: pip install redis"
) from e
if self.config.url:
self._redis = redis.from_url(
@@ -475,7 +485,9 @@ class CronClient:
async def _acquire_lock(self, job_id: str) -> bool:
"""Acquire a distributed lock for a job."""
lock_key = self._key("lock", job_id)
lock_value = hashlib.md5(f"{os.getpid()}-{datetime.utcnow().isoformat()}".encode()).hexdigest()
lock_value = hashlib.md5(
f"{os.getpid()}-{datetime.utcnow().isoformat()}".encode()
).hexdigest()
acquired = await self._redis.set(
lock_key,
@@ -515,7 +527,9 @@ class CronClient:
execution.error = str(e)
execution.completed_at = datetime.utcnow()
execution.duration_ms = int((execution.completed_at - execution.started_at).total_seconds() * 1000)
execution.duration_ms = int(
(execution.completed_at - execution.started_at).total_seconds() * 1000
)
# Update job stats
job.last_run = execution.started_at
@@ -608,7 +622,11 @@ class CronClient:
CronExecution(
job_id=data["job_id"],
started_at=datetime.fromisoformat(data["started_at"]),
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
completed_at=(
datetime.fromisoformat(data["completed_at"])
if data.get("completed_at")
else None
),
success=data.get("success", False),
result=data.get("result"),
error=data.get("error"),
+15 -5
View File
@@ -24,11 +24,15 @@ class DocumentDBConfig(BaseModel):
username: Optional[str] = Field(default=None, description="Authentication username")
password: Optional[str] = Field(default=None, description="Authentication password")
database: str = Field(default="hanzo", description="Default database name")
uri: Optional[str] = Field(default=None, description="Full connection URI (overrides host/port)")
uri: Optional[str] = Field(
default=None, description="Full connection URI (overrides host/port)"
)
auth_source: str = Field(default="admin", description="Authentication database")
replica_set: Optional[str] = Field(default=None, description="Replica set name")
tls: bool = Field(default=False, description="Use TLS/SSL")
server_selection_timeout_ms: int = Field(default=5000, description="Server selection timeout")
server_selection_timeout_ms: int = Field(
default=5000, description="Server selection timeout"
)
connect_timeout_ms: int = Field(default=5000, description="Connection timeout")
socket_timeout_ms: int = Field(default=30000, description="Socket timeout")
@@ -132,7 +136,9 @@ class DocumentDBClient:
try:
from motor.motor_asyncio import AsyncIOMotorClient
except ImportError as e:
raise ImportError("motor is required for DocumentDBClient. Install with: pip install motor") from e
raise ImportError(
"motor is required for DocumentDBClient. Install with: pip install motor"
) from e
if self.config.uri:
self._client = AsyncIOMotorClient(
@@ -198,7 +204,9 @@ class DocumentDBClient:
result = await coll.insert_one(document)
return str(result.inserted_id)
async def insert_many(self, collection: str, documents: Sequence[dict[str, Any]]) -> list[str]:
async def insert_many(
self, collection: str, documents: Sequence[dict[str, Any]]
) -> list[str]:
"""Insert multiple documents.
Args:
@@ -383,7 +391,9 @@ class DocumentDBClient:
result = await coll.delete_one(filter)
return DeleteResult(deleted_count=result.deleted_count)
async def delete_many(self, collection: str, filter: dict[str, Any]) -> DeleteResult:
async def delete_many(
self, collection: str, filter: dict[str, Any]
) -> DeleteResult:
"""Delete multiple documents.
Args:
+20 -8
View File
@@ -17,11 +17,19 @@ from pydantic import Field, BaseModel
class FunctionsConfig(BaseModel):
"""Configuration for Nuclio connection."""
dashboard_url: str = Field(default="http://localhost:8070", description="Nuclio dashboard URL")
api_key: Optional[str] = Field(default=None, description="API key for authentication")
dashboard_url: str = Field(
default="http://localhost:8070", description="Nuclio dashboard URL"
)
api_key: Optional[str] = Field(
default=None, description="API key for authentication"
)
namespace: str = Field(default="nuclio", description="Kubernetes namespace")
default_runtime: str = Field(default="python:3.11", description="Default function runtime")
default_handler: str = Field(default="main:handler", description="Default function handler")
default_runtime: str = Field(
default="python:3.11", description="Default function runtime"
)
default_handler: str = Field(
default="main:handler", description="Default function handler"
)
registry: Optional[str] = Field(default=None, description="Container registry URL")
timeout: float = Field(default=30.0, description="Request timeout in seconds")
@@ -188,7 +196,9 @@ class FunctionsClient:
try:
import httpx
except ImportError as e:
raise ImportError("httpx is required for FunctionsClient. Install with: pip install httpx") from e
raise ImportError(
"httpx is required for FunctionsClient. Install with: pip install httpx"
) from e
self._client = httpx.AsyncClient(
base_url=self.config.dashboard_url,
@@ -314,9 +324,11 @@ class FunctionsClient:
replicas=status.get("replicas", 0),
version=metadata.get("version", ""),
invoke_url=status.get("httpPort"),
internal_url=status.get("internalInvocationUrls", [None])[0]
if status.get("internalInvocationUrls")
else None,
internal_url=(
status.get("internalInvocationUrls", [None])[0]
if status.get("internalInvocationUrls")
else None
),
message=status.get("message"),
)
+15 -5
View File
@@ -22,12 +22,16 @@ class KVConfig(BaseModel):
port: int = Field(default=6379, description="Redis server port")
password: Optional[str] = Field(default=None, description="Redis password")
db: int = Field(default=0, description="Redis database number")
url: Optional[str] = Field(default=None, description="Full Redis URL (overrides host/port)")
url: Optional[str] = Field(
default=None, description="Full Redis URL (overrides host/port)"
)
ssl: bool = Field(default=False, description="Use SSL/TLS")
socket_timeout: float = Field(default=5.0, description="Socket timeout in seconds")
socket_connect_timeout: float = Field(default=5.0, description="Connection timeout")
max_connections: int = Field(default=10, description="Max pool connections")
decode_responses: bool = Field(default=True, description="Decode responses as strings")
decode_responses: bool = Field(
default=True, description="Decode responses as strings"
)
@classmethod
def from_env(cls) -> KVConfig:
@@ -99,7 +103,9 @@ class KVClient:
try:
import redis.asyncio as redis
except ImportError as e:
raise ImportError("redis is required for KVClient. Install with: pip install redis") from e
raise ImportError(
"redis is required for KVClient. Install with: pip install redis"
) from e
if self.config.url:
self._client = redis.from_url(
@@ -336,7 +342,9 @@ class KVClient:
"""
return await self._client.rpush(key, *values)
async def lpop(self, key: str, count: Optional[int] = None) -> Optional[Union[str, list[str]]]:
async def lpop(
self, key: str, count: Optional[int] = None
) -> Optional[Union[str, list[str]]]:
"""Pop values from the head of a list.
Args:
@@ -348,7 +356,9 @@ class KVClient:
"""
return await self._client.lpop(key, count)
async def rpop(self, key: str, count: Optional[int] = None) -> Optional[Union[str, list[str]]]:
async def rpop(
self, key: str, count: Optional[int] = None
) -> Optional[Union[str, list[str]]]:
"""Pop values from the tail of a list.
Args:
+24 -8
View File
@@ -17,16 +17,28 @@ from pydantic import Field, BaseModel
class PubSubConfig(BaseModel):
"""Configuration for NATS connection."""
servers: list[str] = Field(default=["nats://localhost:4222"], description="NATS server URLs")
servers: list[str] = Field(
default=["nats://localhost:4222"], description="NATS server URLs"
)
user: Optional[str] = Field(default=None, description="Username for authentication")
password: Optional[str] = Field(default=None, description="Password for authentication")
password: Optional[str] = Field(
default=None, description="Password for authentication"
)
token: Optional[str] = Field(default=None, description="Token for authentication")
nkey_seed: Optional[str] = Field(default=None, description="NKey seed for authentication")
nkey_seed: Optional[str] = Field(
default=None, description="NKey seed for authentication"
)
tls: bool = Field(default=False, description="Use TLS")
name: str = Field(default="hanzo-client", description="Client name")
connect_timeout: float = Field(default=5.0, description="Connection timeout in seconds")
reconnect_time_wait: float = Field(default=2.0, description="Time between reconnect attempts")
max_reconnect_attempts: int = Field(default=60, description="Max reconnection attempts")
connect_timeout: float = Field(
default=5.0, description="Connection timeout in seconds"
)
reconnect_time_wait: float = Field(
default=2.0, description="Time between reconnect attempts"
)
max_reconnect_attempts: int = Field(
default=60, description="Max reconnection attempts"
)
ping_interval: float = Field(default=120.0, description="Ping interval in seconds")
max_outstanding_pings: int = Field(default=2, description="Max outstanding pings")
@@ -132,7 +144,9 @@ class PubSubClient:
try:
import nats
except ImportError as e:
raise ImportError("nats-py is required for PubSubClient. Install with: pip install nats-py") from e
raise ImportError(
"nats-py is required for PubSubClient. Install with: pip install nats-py"
) from e
connect_opts: dict[str, Any] = {
"servers": self.config.servers,
@@ -268,7 +282,9 @@ class PubSubClient:
Raises:
TimeoutError: If no response within timeout.
"""
response = await self._nc.request(subject, data, timeout=timeout, headers=headers)
response = await self._nc.request(
subject, data, timeout=timeout, headers=headers
)
return Message(
subject=response.subject,
data=response.data,
+24 -6
View File
@@ -27,7 +27,9 @@ class QueuesConfig(BaseModel):
db: int = Field(default=1, description="Redis database number")
url: Optional[str] = Field(default=None, description="Full Redis URL")
prefix: str = Field(default="hanzo:queue", description="Key prefix for queues")
default_timeout: int = Field(default=300, description="Default job timeout in seconds")
default_timeout: int = Field(
default=300, description="Default job timeout in seconds"
)
default_ttl: int = Field(default=86400, description="Default result TTL in seconds")
@classmethod
@@ -97,7 +99,9 @@ class Job:
"error": self.error,
"created_at": self.created_at.isoformat() if self.created_at else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"completed_at": (
self.completed_at.isoformat() if self.completed_at else None
),
"attempts": self.attempts,
"max_attempts": self.max_attempts,
"timeout": self.timeout,
@@ -117,9 +121,21 @@ class Job:
status=JobStatus(data.get("status", "pending")),
result=data.get("result"),
error=data.get("error"),
created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None,
started_at=datetime.fromisoformat(data["started_at"]) if data.get("started_at") else None,
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
created_at=(
datetime.fromisoformat(data["created_at"])
if data.get("created_at")
else None
),
started_at=(
datetime.fromisoformat(data["started_at"])
if data.get("started_at")
else None
),
completed_at=(
datetime.fromisoformat(data["completed_at"])
if data.get("completed_at")
else None
),
attempts=data.get("attempts", 0),
max_attempts=data.get("max_attempts", 3),
timeout=data.get("timeout", 300),
@@ -194,7 +210,9 @@ class QueuesClient:
try:
import redis.asyncio as redis
except ImportError as e:
raise ImportError("redis is required for QueuesClient. Install with: pip install redis") from e
raise ImportError(
"redis is required for QueuesClient. Install with: pip install redis"
) from e
if self.config.url:
self._redis = redis.from_url(
+3 -1
View File
@@ -19,7 +19,9 @@ class SearchConfig(BaseModel):
host: str = Field(default="localhost", description="Meilisearch server host")
port: int = Field(default=7700, description="Meilisearch server port")
api_key: Optional[str] = Field(default=None, description="Master or API key")
url: Optional[str] = Field(default=None, description="Full URL (overrides host/port)")
url: Optional[str] = Field(
default=None, description="Full URL (overrides host/port)"
)
timeout: float = Field(default=30.0, description="Request timeout in seconds")
ssl: bool = Field(default=False, description="Use HTTPS")
+15 -5
View File
@@ -18,15 +18,21 @@ from pydantic import Field, BaseModel
class StorageConfig(BaseModel):
"""Configuration for S3/MinIO connection."""
endpoint_url: Optional[str] = Field(default=None, description="Endpoint URL (required for MinIO)")
endpoint_url: Optional[str] = Field(
default=None, description="Endpoint URL (required for MinIO)"
)
region: str = Field(default="us-east-1", description="AWS region")
access_key: Optional[str] = Field(default=None, description="Access key ID")
secret_key: Optional[str] = Field(default=None, description="Secret access key")
bucket: str = Field(default="hanzo", description="Default bucket name")
use_ssl: bool = Field(default=True, description="Use SSL/TLS")
verify_ssl: bool = Field(default=True, description="Verify SSL certificates")
session_token: Optional[str] = Field(default=None, description="Session token (for temporary credentials)")
addressing_style: str = Field(default="auto", description="Path or virtual addressing style")
session_token: Optional[str] = Field(
default=None, description="Session token (for temporary credentials)"
)
addressing_style: str = Field(
default="auto", description="Path or virtual addressing style"
)
@classmethod
def from_env(cls) -> StorageConfig:
@@ -124,7 +130,9 @@ class StorageClient:
from aiobotocore.session import get_session
except ImportError as e:
raise ImportError("aiobotocore is required for StorageClient. Install with: pip install aiobotocore") from e
raise ImportError(
"aiobotocore is required for StorageClient. Install with: pip install aiobotocore"
) from e
self._session = get_session()
self._exit_stack = AsyncExitStack()
@@ -191,7 +199,9 @@ class StorageClient:
bucket_name = bucket or self.config.bucket
create_config = {}
if self.config.region != "us-east-1":
create_config["CreateBucketConfiguration"] = {"LocationConstraint": self.config.region}
create_config["CreateBucketConfiguration"] = {
"LocationConstraint": self.config.region
}
await self._client.create_bucket(Bucket=bucket_name, **create_config)
async def delete_bucket(self, bucket: Optional[str] = None) -> None:
+30 -10
View File
@@ -22,13 +22,21 @@ class TasksConfig(BaseModel):
host: str = Field(default="localhost", description="Temporal server host")
port: int = Field(default=7233, description="Temporal server port")
namespace: str = Field(default="default", description="Temporal namespace")
target: Optional[str] = Field(default=None, description="Full target address (overrides host/port)")
target: Optional[str] = Field(
default=None, description="Full target address (overrides host/port)"
)
tls: bool = Field(default=False, description="Use TLS")
tls_cert_path: Optional[str] = Field(default=None, description="Path to TLS certificate")
tls_cert_path: Optional[str] = Field(
default=None, description="Path to TLS certificate"
)
tls_key_path: Optional[str] = Field(default=None, description="Path to TLS key")
api_key: Optional[str] = Field(default=None, description="API key for Temporal Cloud")
api_key: Optional[str] = Field(
default=None, description="API key for Temporal Cloud"
)
identity: str = Field(default="hanzo-client", description="Client identity")
data_converter: Optional[str] = Field(default=None, description="Custom data converter class")
data_converter: Optional[str] = Field(
default=None, description="Custom data converter class"
)
@classmethod
def from_env(cls) -> TasksConfig:
@@ -179,7 +187,9 @@ class TasksClient:
try:
from temporalio.client import Client, TLSConfig
except ImportError as e:
raise ImportError("temporalio is required for TasksClient. Install with: pip install temporalio") from e
raise ImportError(
"temporalio is required for TasksClient. Install with: pip install temporalio"
) from e
connect_kwargs: dict[str, Any] = {
"target_host": self.config.effective_target,
@@ -281,7 +291,9 @@ class TasksClient:
"arg": args,
"id": id or f"{workflow}-{uuid.uuid4().hex[:8]}",
"task_queue": task_queue,
"id_reuse_policy": policy_map.get(id_reuse_policy, WorkflowIDReusePolicy.ALLOW_DUPLICATE),
"id_reuse_policy": policy_map.get(
id_reuse_policy, WorkflowIDReusePolicy.ALLOW_DUPLICATE
),
}
if execution_timeout:
@@ -299,8 +311,12 @@ class TasksClient:
if retry_policy:
start_kwargs["retry_policy"] = RetryPolicy(
initial_interval=timedelta(seconds=retry_policy.get("initial_interval", 1)),
maximum_interval=timedelta(seconds=retry_policy.get("maximum_interval", 100)),
initial_interval=timedelta(
seconds=retry_policy.get("initial_interval", 1)
),
maximum_interval=timedelta(
seconds=retry_policy.get("maximum_interval", 100)
),
backoff_coefficient=retry_policy.get("backoff_coefficient", 2.0),
maximum_attempts=retry_policy.get("maximum_attempts", 0),
)
@@ -379,7 +395,9 @@ class TasksClient:
start_time=wf.start_time,
close_time=wf.close_time,
memo=dict(wf.memo) if wf.memo else {},
search_attributes=dict(wf.search_attributes) if wf.search_attributes else {},
search_attributes=(
dict(wf.search_attributes) if wf.search_attributes else {}
),
)
async def count_workflows(self, query: str = "") -> int:
@@ -444,7 +462,9 @@ class TasksClient:
specs.append(ScheduleSpec(calendars=[ScheduleCalendarSpec(**calendar)]))
if not specs:
raise ValueError("At least one of cron, interval, or calendar must be specified")
raise ValueError(
"At least one of cron, interval, or calendar must be specified"
)
schedule_spec = (
specs[0]
+9 -3
View File
@@ -20,7 +20,9 @@ class VectorConfig(BaseModel):
port: int = Field(default=6333, description="Qdrant REST API port")
grpc_port: int = Field(default=6334, description="Qdrant gRPC port")
api_key: Optional[str] = Field(default=None, description="API key for Qdrant Cloud")
url: Optional[str] = Field(default=None, description="Full URL (overrides host/port)")
url: Optional[str] = Field(
default=None, description="Full URL (overrides host/port)"
)
https: bool = Field(default=False, description="Use HTTPS")
timeout: float = Field(default=30.0, description="Request timeout in seconds")
prefer_grpc: bool = Field(default=True, description="Prefer gRPC over REST")
@@ -218,8 +220,12 @@ class VectorClient:
"""
from qdrant_client.models import PointStruct
qdrant_points = [PointStruct(id=p.id, vector=p.vector, payload=p.payload) for p in points]
await self._async_client.upsert(collection_name=collection, points=qdrant_points)
qdrant_points = [
PointStruct(id=p.id, vector=p.vector, payload=p.payload) for p in points
]
await self._async_client.upsert(
collection_name=collection, points=qdrant_points
)
async def search(
self,
+13 -4
View File
@@ -42,7 +42,9 @@ def _get_jobs() -> List[Dict[str, Any]]:
import urllib.request
endpoint = os.getenv("HANZO_CLUSTER_URL", "http://localhost:8000")
req = urllib.request.urlopen(f"{endpoint}/jobs?limit=5", timeout=2) # noqa: S310
req = urllib.request.urlopen( # noqa: S310
f"{endpoint}/jobs?limit=5", timeout=2
)
return json.loads(req.read().decode()).get("jobs", [])
except Exception:
return []
@@ -55,7 +57,9 @@ def _get_logs() -> List[str]:
import urllib.request
endpoint = os.getenv("HANZO_CLUSTER_URL", "http://localhost:8000")
req = urllib.request.urlopen(f"{endpoint}/logs?limit=5", timeout=2) # noqa: S310
req = urllib.request.urlopen( # noqa: S310
f"{endpoint}/logs?limit=5", timeout=2
)
return json.loads(req.read().decode()).get("logs", [])
except Exception:
return []
@@ -99,7 +103,9 @@ def run_dashboard(refresh_rate: float = 1.0):
cluster = _get_cluster_status()
if cluster:
table.add_row("Status", f"[green]{cluster.get('status', 'unknown')}[/green]")
table.add_row(
"Status", f"[green]{cluster.get('status', 'unknown')}[/green]"
)
table.add_row("Nodes", str(cluster.get("nodes", 0)))
table.add_row("Models", ", ".join(cluster.get("models", [])) or "none")
table.add_row("Port", str(cluster.get("port", "-")))
@@ -161,7 +167,10 @@ def run_dashboard(refresh_rate: float = 1.0):
"""Get logs panel."""
logs = _get_logs()
if logs:
log_text = "\n".join(f"[dim]{log.get('time', '')}[/dim] {log.get('message', '')}" for log in logs[:5])
log_text = "\n".join(
f"[dim]{log.get('time', '')}[/dim] {log.get('message', '')}"
for log in logs[:5]
)
border = "dim"
else:
log_text = "[dim]No logs available[/dim]"
+115 -35
View File
@@ -98,7 +98,9 @@ class EnhancedHanzoREPL:
self.failed_tools = set() # Track tools that have failed this session
# Initialize background task manager
self.task_manager = BackgroundTaskManager(console) if BackgroundTaskManager else None
self.task_manager = (
BackgroundTaskManager(console) if BackgroundTaskManager else None
)
# Initialize todo manager
self.todo_manager = TodoManager(console) if TodoManager else None
@@ -112,7 +114,9 @@ class EnhancedHanzoREPL:
if default_tool:
self.current_model = f"tool:{default_tool.name}"
self.current_tool = default_tool
self.console.print(f"[green]✓ Detected {default_tool.display_name} as default AI assistant[/green]")
self.console.print(
f"[green]✓ Detected {default_tool.display_name} as default AI assistant[/green]"
)
else:
# Fallback to regular models
self.current_model = self.config.get("default_model", "gpt-3.5-turbo")
@@ -351,7 +355,9 @@ class EnhancedHanzoREPL:
# Router status
try:
response = httpx.get("http://localhost:4000/health", timeout=1)
router_status = "✅ Running" if response.status_code == 200 else "⚠️ Unhealthy"
router_status = (
"✅ Running" if response.status_code == 200 else "⚠️ Unhealthy"
)
router_details = "Port 4000"
except Exception:
router_status = "❌ Offline"
@@ -361,7 +367,9 @@ class EnhancedHanzoREPL:
# Node status
try:
response = httpx.get("http://localhost:3690/health", timeout=1)
node_status = "✅ Running" if response.status_code == 200 else "⚠️ Unhealthy"
node_status = (
"✅ Running" if response.status_code == 200 else "⚠️ Unhealthy"
)
node_details = "Port 3690"
except Exception:
node_status = "❌ Offline"
@@ -404,7 +412,11 @@ class EnhancedHanzoREPL:
args = f"tool:{tool.name}"
else:
# It's a model selection
model_idx = num - len(self.detected_tools) - 1 if self.detected_tools else num - 1
model_idx = (
num - len(self.detected_tools) - 1
if self.detected_tools
else num - 1
)
models_list = list(self.MODELS.keys())
if 0 <= model_idx < len(models_list):
args = models_list[model_idx]
@@ -417,7 +429,11 @@ class EnhancedHanzoREPL:
return
# Check if it's a tool
if args.startswith("tool:") or args in [t.name for t in self.detected_tools] if self.detected_tools else False:
if (
args.startswith("tool:") or args in [t.name for t in self.detected_tools]
if self.detected_tools
else False
):
# Handle tool selection
tool_name = args.replace("tool:", "") if args.startswith("tool:") else args
@@ -451,7 +467,9 @@ class EnhancedHanzoREPL:
# Show hint if it's a gateway model (not in our predefined list)
if args not in self.MODELS and not args.startswith("local:"):
self.console.print("[dim]Using gateway model - all models free! Use /models to see full list.[/dim]")
self.console.print(
"[dim]Using gateway model - all models free! Use /models to see full list.[/dim]"
)
async def list_tools(self, args: str = ""):
"""List available AI tools."""
@@ -464,10 +482,14 @@ class EnhancedHanzoREPL:
"""List available models from gateway.hanzo.ai."""
# Show tools first if available
if self.detected_tools:
self.console.print("[bold cyan]AI Coding Assistants (Detected):[/bold cyan]")
self.console.print(
"[bold cyan]AI Coding Assistants (Detected):[/bold cyan]"
)
for i, tool in enumerate(self.detected_tools, 1):
marker = "→" if self.current_model == f"tool:{tool.name}" else " "
self.console.print(f" {marker} {i}. {tool.display_name} ({tool.provider})")
self.console.print(
f" {marker} {i}. {tool.display_name} ({tool.provider})"
)
self.console.print()
# Try to fetch live models from gateway
@@ -487,14 +509,20 @@ class EnhancedHanzoREPL:
models_list = [
model
for model in all_models
if not any(keyword in model.get("id", "").lower() for keyword in embedding_keywords)
if not any(
keyword in model.get("id", "").lower()
for keyword in embedding_keywords
)
]
except Exception:
pass
# Fallback to static models if gateway fetch fails
if not models_list:
models_list = [{"id": model_id, "name": model_name} for model_id, model_name in self.MODELS.items()]
models_list = [
{"id": model_id, "name": model_name}
for model_id, model_name in self.MODELS.items()
]
# Create table
table = Table(title="Gateway LLMs (Chat Models)", box=box.ROUNDED)
@@ -533,16 +561,22 @@ class EnhancedHanzoREPL:
# Highlight current model
if model_id == self.current_model:
table.add_row(str(i), f"[bold green]→ {model_id}[/bold green]", tier, provider)
table.add_row(
str(i), f"[bold green]→ {model_id}[/bold green]", tier, provider
)
else:
table.add_row(str(i), model_id, tier, provider)
self.console.print(table)
self.console.print("\n[dim cyan]Free tier:[/dim cyan] [green]Most models available without login![/green]")
self.console.print(
"\n[dim cyan]Free tier:[/dim cyan] [green]Most models available without login![/green]"
)
self.console.print(
"[dim cyan]Premium models:[/dim cyan] [yellow]gpt-4, claude-3-opus, o1-preview[/yellow] - [cyan]hanzo auth login[/cyan]"
)
self.console.print("\n[dim]Use /model <name> or /model <number> to switch[/dim]")
self.console.print(
"\n[dim]Use /model <name> or /model <number> to switch[/dim]"
)
async def login(self, args: str = ""):
"""Login to Hanzo."""
@@ -661,7 +695,9 @@ class EnhancedHanzoREPL:
self.console.print(f"[dim]Executing: {full_cmd}[/dim]")
try:
result = subprocess.run(full_cmd, shell=True, capture_output=True, text=True)
result = subprocess.run(
full_cmd, shell=True, capture_output=True, text=True
)
if result.stdout:
self.console.print(result.stdout)
@@ -682,13 +718,19 @@ class EnhancedHanzoREPL:
if tool.name not in self.failed_tools:
self.current_tool = tool
self.current_model = f"tool:{tool.name}"
self.console.print(f"[yellow]Switched to {tool.display_name}[/yellow]")
self.console.print(
f"[yellow]Switched to {tool.display_name}[/yellow]"
)
break
# Try the current tool
if self.current_tool.name not in self.failed_tools:
self.console.print(f"[dim]Using {self.current_tool.display_name}...[/dim]")
success, output = self.tool_detector.execute_with_tool(self.current_tool, message)
self.console.print(
f"[dim]Using {self.current_tool.display_name}...[/dim]"
)
success, output = self.tool_detector.execute_with_tool(
self.current_tool, message
)
if success:
self.console.print(output)
@@ -703,8 +745,12 @@ class EnhancedHanzoREPL:
if self.tool_detector and self.tool_detector.detected_tools:
for fallback_tool in self.tool_detector.detected_tools:
if fallback_tool.name not in self.failed_tools:
self.console.print(f"[yellow]Trying {fallback_tool.display_name}...[/yellow]")
success, output = self.tool_detector.execute_with_tool(fallback_tool, message)
self.console.print(
f"[yellow]Trying {fallback_tool.display_name}...[/yellow]"
)
success, output = self.tool_detector.execute_with_tool(
fallback_tool, message
)
if success:
self.console.print(output)
# Automatically switch to this working tool
@@ -720,15 +766,21 @@ class EnhancedHanzoREPL:
else:
# Mark as failed
self.failed_tools.add(fallback_tool.name)
self.console.print(f"[red]{fallback_tool.display_name} also failed[/red]")
self.console.print(
f"[red]{fallback_tool.display_name} also failed[/red]"
)
if not found_working:
# Final fallback to cloud model
self.console.print(f"[yellow]Falling back to cloud model...[/yellow]")
await self.execute_command("ask", f"--cloud --model gpt-3.5-turbo {message}")
await self.execute_command(
"ask", f"--cloud --model gpt-3.5-turbo {message}"
)
else:
# Use regular model through hanzo ask
await self.execute_command("ask", f"--cloud --model {self.current_model} {message}")
await self.execute_command(
"ask", f"--cloud --model {self.current_model} {message}"
)
async def quick_model_select(self, args: str = ""):
"""Quick model selector with arrow keys."""
@@ -737,7 +789,11 @@ class EnhancedHanzoREPL:
return
# Prepare tools and models
tools = [(f"tool:{t.name}", t.display_name) for t in self.detected_tools] if self.detected_tools else []
tools = (
[(f"tool:{t.name}", t.display_name) for t in self.detected_tools]
if self.detected_tools
else []
)
models = list(self.MODELS.items())
selector = QuickModelSelector(models, tools, self.current_model)
@@ -768,7 +824,9 @@ class EnhancedHanzoREPL:
else:
# Show tasks and prompt for selection
self.task_manager.list_tasks()
self.console.print("\n[cyan]Enter task ID to kill (or 'all' for all tasks):[/cyan]")
self.console.print(
"\n[cyan]Enter task ID to kill (or 'all' for all tasks):[/cyan]"
)
try:
task_id = await self.session.prompt_async("> ")
if task_id:
@@ -803,7 +861,9 @@ class EnhancedHanzoREPL:
# Quick add
try:
todo = self.todo_manager.quick_add(rest)
self.console.print(f"[green]✅ Added todo: {todo.title} (ID: {todo.id})[/green]")
self.console.print(
f"[green]✅ Added todo: {todo.title} (ID: {todo.id})[/green]"
)
except ValueError as e:
self.console.print(f"[red]Error: {e}[/red]")
else:
@@ -829,7 +889,9 @@ class EnhancedHanzoREPL:
elif key in ["tag", "t"]:
tag = value
todos = self.todo_manager.list_todos(status=status, priority=priority, tag=tag)
todos = self.todo_manager.list_todos(
status=status, priority=priority, tag=tag
)
title = "Filtered Todos" if (status or priority or tag) else "All Todos"
self.todo_manager.display_todos(todos, title)
@@ -838,7 +900,9 @@ class EnhancedHanzoREPL:
if rest:
todo = self.todo_manager.update_todo(rest, status="done")
if todo:
self.console.print(f"[green]✅ Marked as done: {todo.title}[/green]")
self.console.print(
f"[green]✅ Marked as done: {todo.title}[/green]"
)
else:
self.console.print(f"[red]Todo not found: {rest}[/red]")
else:
@@ -894,7 +958,9 @@ class EnhancedHanzoREPL:
elif subcommand in ["clear", "reset"]:
# Clear all todos (with confirmation)
try:
confirm = await self.session.prompt_async("Are you sure you want to delete ALL todos? (yes/no): ")
confirm = await self.session.prompt_async(
"Are you sure you want to delete ALL todos? (yes/no): "
)
if confirm.lower() in ["yes", "y"]:
self.todo_manager.todos = []
self.todo_manager.save_todos()
@@ -912,9 +978,13 @@ class EnhancedHanzoREPL:
# Unknown subcommand, treat as quick add
try:
todo = self.todo_manager.quick_add(args)
self.console.print(f"[green]✅ Added todo: {todo.title} (ID: {todo.id})[/green]")
self.console.print(
f"[green]✅ Added todo: {todo.title} (ID: {todo.id})[/green]"
)
except ValueError:
self.console.print(f"[yellow]Unknown todo command: {subcommand}[/yellow]")
self.console.print(
f"[yellow]Unknown todo command: {subcommand}[/yellow]"
)
self.console.print("[dim]Use /todo help for available commands[/dim]")
async def add_todo_interactive(self):
@@ -930,13 +1000,21 @@ class EnhancedHanzoREPL:
description = await self.session.prompt_async("Description (optional): ")
# Get priority
priority = await self.session.prompt_async("Priority (low/medium/high/urgent) [medium]: ")
priority = await self.session.prompt_async(
"Priority (low/medium/high/urgent) [medium]: "
)
if not priority:
priority = "medium"
# Get tags
tags_input = await self.session.prompt_async("Tags (comma-separated, optional): ")
tags = [t.strip() for t in tags_input.split(",") if t.strip()] if tags_input else []
tags_input = await self.session.prompt_async(
"Tags (comma-separated, optional): "
)
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
# Get due date
due_date = await self.session.prompt_async("Due date (optional): ")
@@ -950,7 +1028,9 @@ class EnhancedHanzoREPL:
due_date=due_date if due_date else None,
)
self.console.print(f"[green]✅ Added todo: {todo.title} (ID: {todo.id})[/green]")
self.console.print(
f"[green]✅ Added todo: {todo.title} (ID: {todo.id})[/green]"
)
except (KeyboardInterrupt, EOFError):
self.console.print("[yellow]Cancelled[/yellow]")
@@ -14,7 +14,9 @@ from prompt_toolkit.layout.containers import HSplit, Window
class QuickModelSelector:
"""Quick model selector with arrow navigation."""
def __init__(self, models: List[Tuple[str, str]], tools: List[Tuple[str, str]], current: str):
def __init__(
self, models: List[Tuple[str, str]], tools: List[Tuple[str, str]], current: str
):
self.models = models
self.tools = tools
self.current = current
@@ -97,7 +99,9 @@ class QuickModelSelector:
layout = Layout(Window(FormattedTextControl(get_text), wrap_lines=False))
app = Application(layout=layout, key_bindings=kb, full_screen=False, mouse_support=True)
app = Application(
layout=layout, key_bindings=kb, full_screen=False, mouse_support=True
)
return await app.run_async()
@@ -134,7 +138,9 @@ class BackgroundTaskManager:
task = self.tasks[task_id]
if task["process"].poll() is None:
task["process"].terminate()
self.console.print(f"[yellow]Terminated {task_id}: {task['name']}[/yellow]")
self.console.print(
f"[yellow]Terminated {task_id}: {task['name']}[/yellow]"
)
else:
self.console.print(f"[dim]Task {task_id} already stopped[/dim]")
del self.tasks[task_id]
+3 -1
View File
@@ -196,7 +196,9 @@ hanzo> mcp run read_file --arg path=README.md
except subprocess.TimeoutExpired:
self.console.print("[red]Command timed out[/red]")
except FileNotFoundError:
self.console.print("[red]Command not found. Make sure 'hanzo' is installed.[/red]")
self.console.print(
"[red]Command not found. Make sure 'hanzo' is installed.[/red]"
)
except Exception as e:
self.console.print(f"[red]Command error: {e}[/red]")
@@ -293,7 +293,9 @@ class TodoManager:
TodoPriority.LOW: "green",
}.get(todo.priority, "white")
priority_display = f"[{priority_color}]{todo.priority.value.upper()}[/{priority_color}]"
priority_display = (
f"[{priority_color}]{todo.priority.value.upper()}[/{priority_color}]"
)
# Tags
tags_display = ", ".join(todo.tags) if todo.tags else "-"
@@ -387,7 +389,9 @@ class TodoManager:
if not title:
raise ValueError("Todo title cannot be empty")
return self.add_todo(title=title, priority=priority, tags=tags, due_date=due_date)
return self.add_todo(
title=title, priority=priority, tags=tags, due_date=due_date
)
def get_statistics(self) -> Dict[str, Any]:
"""Get todo statistics."""
+9 -3
View File
@@ -57,7 +57,9 @@ class MemoryManager:
try:
with open(self.memory_file, "r") as f:
data = json.load(f)
self.memories = [MemoryItem.from_dict(item) for item in data.get("memories", [])]
self.memories = [
MemoryItem.from_dict(item) for item in data.get("memories", [])
]
except Exception as e:
print(f"Error loading memories: {e}")
self.memories = []
@@ -178,7 +180,9 @@ class MemoryManager:
self.memories = []
self.save_memories()
def get_memories(self, type: str = None, tags: List[str] = None) -> List[MemoryItem]:
def get_memories(
self, type: str = None, tags: List[str] = None
) -> List[MemoryItem]:
"""Get memories filtered by type or tags."""
result = self.memories
@@ -331,7 +335,9 @@ def handle_memory_command(command: str, memory_manager: MemoryManager, console)
console.print("[yellow]No memories stored.[/yellow]")
return True
table = Table(title="Current Memories", show_header=True, header_style="bold magenta")
table = Table(
title="Current Memories", show_header=True, header_style="bold magenta"
)
table.add_column("ID", style="cyan", width=10)
table.add_column("Type", width=12)
table.add_column("Content", width=50)
+3 -1
View File
@@ -16,7 +16,9 @@ from typing import Any, Dict, List, Optional
from dataclasses import field, dataclass
def check_local_node(host: str = "localhost", port: int = 4000, timeout: float = 0.5) -> bool:
def check_local_node(
host: str = "localhost", port: int = 4000, timeout: float = 0.5
) -> bool:
"""Check if hanzo-node is running locally.
Args:
+19 -6
View File
@@ -275,10 +275,18 @@ class SmartRateLimiter:
# Default configs for known APIs
self.configs = {
"openai": RateLimitConfig(requests_per_minute=60, requests_per_hour=1000, burst_size=10),
"anthropic": RateLimitConfig(requests_per_minute=50, requests_per_hour=1000, burst_size=5),
"local": RateLimitConfig(requests_per_minute=100, requests_per_hour=10000, burst_size=20),
"free": RateLimitConfig(requests_per_minute=10, requests_per_hour=100, burst_size=2),
"openai": RateLimitConfig(
requests_per_minute=60, requests_per_hour=1000, burst_size=10
),
"anthropic": RateLimitConfig(
requests_per_minute=50, requests_per_hour=1000, burst_size=5
),
"local": RateLimitConfig(
requests_per_minute=100, requests_per_hour=10000, burst_size=20
),
"free": RateLimitConfig(
requests_per_minute=10, requests_per_hour=100, burst_size=2
),
}
def get_limiter(self, api_type: str) -> RateLimiter:
@@ -288,7 +296,9 @@ class SmartRateLimiter:
self.limiters[api_type] = RateLimiter(config)
return self.limiters[api_type]
async def execute_with_limit(self, api_type: str, func: Callable, *args, **kwargs) -> Any:
async def execute_with_limit(
self, api_type: str, func: Callable, *args, **kwargs
) -> Any:
"""Execute function with appropriate rate limiting."""
limiter = self.get_limiter(api_type)
recovery = ErrorRecovery(limiter)
@@ -297,7 +307,10 @@ class SmartRateLimiter:
def get_all_status(self) -> Dict[str, Dict[str, Any]]:
"""Get status of all limiters."""
return {api_type: limiter.get_status() for api_type, limiter in self.limiters.items()}
return {
api_type: limiter.get_status()
for api_type, limiter in self.limiters.items()
}
# Global instance for easy use
+21 -7
View File
@@ -25,7 +25,9 @@ class StreamingHandler:
async def stream_openai(self, client, messages: list, model: str = "gpt-4") -> str:
"""Stream response from OpenAI API."""
try:
stream = await client.chat.completions.create(model=model, messages=messages, stream=True, max_tokens=1000)
stream = await client.chat.completions.create(
model=model, messages=messages, stream=True, max_tokens=1000
)
self.current_response = ""
self.is_streaming = True
@@ -61,7 +63,9 @@ class StreamingHandler:
self.is_streaming = False
return None
async def stream_anthropic(self, client, messages: list, model: str = "claude-3-5-sonnet-20241022") -> str:
async def stream_anthropic(
self, client, messages: list, model: str = "claude-3-5-sonnet-20241022"
) -> str:
"""Stream response from Anthropic API."""
try:
self.current_response = ""
@@ -77,7 +81,9 @@ class StreamingHandler:
console=self.console,
refresh_per_second=10,
) as live:
async with client.messages.stream(model=model, messages=messages, max_tokens=1000) as stream:
async with client.messages.stream(
model=model, messages=messages, max_tokens=1000
) as stream:
async for text in stream.text_stream:
self.current_response += text
live.update(
@@ -218,11 +224,15 @@ class TypewriterEffect:
for line in lines:
for char in line:
current_code += char
syntax = Syntax(current_code, language, theme="monokai", line_numbers=True)
syntax = Syntax(
current_code, language, theme="monokai", line_numbers=True
)
live.update(syntax)
await asyncio.sleep(speed)
current_code += "\n"
syntax = Syntax(current_code, language, theme="monokai", line_numbers=True)
syntax = Syntax(
current_code, language, theme="monokai", line_numbers=True
)
live.update(syntax)
@@ -240,7 +250,9 @@ async def stream_with_fallback(message: str, console: Console = None) -> Optiona
from openai import AsyncOpenAI
client = AsyncOpenAI()
return await handler.stream_openai(client, [{"role": "user", "content": message}])
return await handler.stream_openai(
client, [{"role": "user", "content": message}]
)
except Exception as e:
if console:
console.print(f"[yellow]OpenAI streaming failed: {e}[/yellow]")
@@ -251,7 +263,9 @@ async def stream_with_fallback(message: str, console: Console = None) -> Optiona
from anthropic import AsyncAnthropic
client = AsyncAnthropic()
return await handler.stream_anthropic(client, [{"role": "user", "content": message}])
return await handler.stream_anthropic(
client, [{"role": "user", "content": message}]
)
except Exception as e:
if console:
console.print(f"[yellow]Anthropic streaming failed: {e}[/yellow]")
+23 -7
View File
@@ -217,7 +217,9 @@ class ToolDetector:
]: # 400/422 means endpoint exists but params wrong
tool.detected = True
tool.version = f"Running (Port {port})"
tool.api_endpoint = f"http://localhost:{port}/health" # Update port
tool.api_endpoint = (
f"http://localhost:{port}/health" # Update port
)
# Try to get model info
try:
@@ -314,7 +316,11 @@ class ToolDetector:
version = tool.version or "Unknown" if tool.detected else "-"
# Highlight the default tool
if tool.detected and tool == self.detected_tools[0] if self.detected_tools else False:
if (
tool.detected and tool == self.detected_tools[0]
if self.detected_tools
else False
):
table.add_row(
str(i),
f"[bold green]→ {tool.display_name}[/bold green]",
@@ -341,11 +347,15 @@ class ToolDetector:
# Special message for Hanzo Node
if default.name == "hanzod":
self.console.print("[cyan]🔒 Using local private AI - your data stays on your machine[/cyan]")
self.console.print(
"[cyan]🔒 Using local private AI - your data stays on your machine[/cyan]"
)
self.console.print("[dim]Manage models with: hanzo node models[/dim]")
else:
self.console.print("\n[yellow]No AI coding tools detected.[/yellow]")
self.console.print("[dim]Start Hanzo Node for local AI: hanzo node start[/dim]")
self.console.print(
"[dim]Start Hanzo Node for local AI: hanzo node start[/dim]"
)
self.console.print("[dim]Or install Claude Code, OpenAI CLI, etc.[/dim]")
def get_tool_command(self, tool: AITool, prompt: str) -> List[str]:
@@ -407,7 +417,9 @@ class ToolDetector:
)
if response.status_code == 200:
result = response.json()
return True, result.get("choices", [{}])[0].get("message", {}).get("content", "")
return True, result.get("choices", [{}])[0].get(
"message", {}
).get("content", "")
else:
return (
False,
@@ -431,7 +443,9 @@ class ToolDetector:
)
if response.status_code == 200:
result = response.json()
return True, result.get("choices", [{}])[0].get("message", {}).get("content", "")
return True, result.get("choices", [{}])[0].get(
"message", {}
).get("content", "")
except Exception as e:
return False, f"Router error: {e}"
@@ -460,6 +474,8 @@ class ToolDetector:
if success:
return True, output, tool
else:
self.console.print(f"[yellow]{tool.display_name} failed: {output}[/yellow]")
self.console.print(
f"[yellow]{tool.display_name} failed: {output}[/yellow]"
)
return False, "No available tools could handle the request", None
+6 -2
View File
@@ -33,7 +33,9 @@ class InlineStartup:
# Check last shown time
if self.last_shown_file.exists():
try:
last_shown = datetime.fromisoformat(self.last_shown_file.read_text().strip())
last_shown = datetime.fromisoformat(
self.last_shown_file.read_text().strip()
)
if datetime.now() - last_shown < self.show_interval:
return False
except Exception:
@@ -63,7 +65,9 @@ class InlineStartup:
message.append("Router management, improved docs", style="yellow dim")
# Show panel
console.print(Panel(message, box=box.MINIMAL, border_style="cyan", padding=(0, 1)))
console.print(
Panel(message, box=box.MINIMAL, border_style="cyan", padding=(0, 1))
)
self.mark_shown()
+9 -3
View File
@@ -232,7 +232,9 @@ class StartupUI:
# Check router status
try:
response = httpx.get("http://localhost:4000/health", timeout=1)
router_status = "🟢 Running" if response.status_code == 200 else "🔴 Offline"
router_status = (
"🟢 Running" if response.status_code == 200 else "🔴 Offline"
)
except Exception:
router_status = "⚫ Offline"
@@ -256,7 +258,9 @@ class StartupUI:
status.append("API: ", style="bold")
status.append(api_status, style="white")
return Panel(Align.center(status), box=box.ROUNDED, border_style="blue", padding=(0, 1))
return Panel(
Align.center(status), box=box.ROUNDED, border_style="blue", padding=(0, 1)
)
def _create_qr_panel(self) -> Panel:
"""Create compact QR code panel for device connection."""
@@ -299,7 +303,9 @@ class StartupUI:
gpu_info = caps.chip[:20]
except Exception:
# Fallback - basic connection info
connection_data = json.dumps({"id": socket.gethostname(), "host": local_ip, "type": "hanzo-node"})
connection_data = json.dumps(
{"id": socket.gethostname(), "host": local_ip, "type": "hanzo-node"}
)
gpu_info = "CPU"
# Generate smallest possible QR code
+11 -3
View File
@@ -39,7 +39,11 @@ def get_iam_token() -> Optional[str]:
try:
auth = json.loads(AUTH_FILE.read_text())
# CLI login stores flat "token", worker/IAM stores nested "tokens.access_token"
return auth.get("token") or auth.get("api_key") or auth.get("tokens", {}).get("access_token")
return (
auth.get("token")
or auth.get("api_key")
or auth.get("tokens", {}).get("access_token")
)
except Exception:
pass
return None
@@ -272,7 +276,9 @@ class PaaSClient:
def get(self, url: str, **kwargs) -> Optional[dict]:
return self._request("GET", url, **kwargs)
def post(self, url: str, payload: Optional[dict] = None, **kwargs) -> Optional[dict]:
def post(
self, url: str, payload: Optional[dict] = None, **kwargs
) -> Optional[dict]:
return self._request("POST", url, json=payload, **kwargs)
def put(self, url: str, payload: Optional[dict] = None, **kwargs) -> Optional[dict]:
@@ -306,7 +312,9 @@ class PaaSClient:
if resp.status_code == 401:
new_token = self._reauth()
if new_token:
resp = client.request(method, url, headers=self.headers, **kwargs)
resp = client.request(
method, url, headers=self.headers, **kwargs
)
else:
return self._handle_response(resp)
+11 -3
View File
@@ -14,7 +14,11 @@ def get_config_paths() -> Dict[str, Path]:
# System config
if os.name == "nt": # Windows
paths["system"] = Path(os.environ.get("PROGRAMDATA", "C:\\ProgramData")) / "hanzo" / "config.yaml"
paths["system"] = (
Path(os.environ.get("PROGRAMDATA", "C:\\ProgramData"))
/ "hanzo"
/ "config.yaml"
)
else: # Unix-like
paths["system"] = Path("/etc/hanzo/config.yaml")
@@ -138,7 +142,9 @@ def init_config() -> Dict[str, Path]:
def get_default_model() -> str:
"""Get default model from config or environment."""
return os.environ.get("HANZO_DEFAULT_MODEL") or get_config_value("default_model", "llama-3.2-3b")
return os.environ.get("HANZO_DEFAULT_MODEL") or get_config_value(
"default_model", "llama-3.2-3b"
)
def get_api_key(provider: str) -> Optional[str]:
@@ -161,4 +167,6 @@ def get_api_key(provider: str) -> Optional[str]:
def is_local_preferred() -> bool:
"""Check if local execution is preferred."""
return os.environ.get("HANZO_USE_LOCAL", "").lower() == "true" or get_config_value("prefer_local", False)
return os.environ.get("HANZO_USE_LOCAL", "").lower() == "true" or get_config_value(
"prefer_local", False
)
+6 -2
View File
@@ -44,7 +44,9 @@ def check_net_installation() -> Tuple[bool, Optional[str], Optional[str]]:
return False, str(net_path), str(venv_python)
# No venv, check system Python
result = subprocess.run([sys.executable, "-c", "import scapy"], capture_output=True, text=True)
result = subprocess.run(
[sys.executable, "-c", "import scapy"], capture_output=True, text=True
)
if result.returncode == 0:
return True, str(net_path), sys.executable
@@ -102,7 +104,9 @@ def get_missing_dependencies(python_exe: str = None) -> list:
missing = []
for package in required_packages:
result = subprocess.run([python_exe, "-c", f"import {package}"], capture_output=True, text=True)
result = subprocess.run(
[python_exe, "-c", f"import {package}"], capture_output=True, text=True
)
if result.returncode != 0:
missing.append(package)