feat(cli,mcp): complete S3 control surface — hanzo s3 + s3 MCP tool

Closes the last gap in the one-way Hanzo control surface. IAM/KMS/PaaS
already had both CLI subcommands and MCP tools; S3 had neither.

- hanzo s3: buckets/mb/rb/ls/stat/rm/presign over hanzo_s3 (MinIO) client,
  creds from env (inject from KMS, never plaintext). Registered in the
  unified hanzo CLI alongside iam/kms/paas.
- hanzo-tools-s3: S3Tool MCP tool (same action-dispatch pattern as PaaSTool),
  exported via the hanzo.tools entry point; s3 added to loader prefix map.
- uv workspace: map hanzo-cli/iam/kms/s3 + tools-s3 to local sources so the
  unified CLI resolves on-disk packages (fixes hanzo_iam ImportError from a
  stale published 1.29.0 wheel shadowing local 1.30.0).

Tests: 7 CLI + 14 MCP, all green.
This commit is contained in:
2026-06-24 13:50:24 -07:00
parent 5d3f6a1eeb
commit f6c516868c
14 changed files with 861 additions and 1 deletions
+3
View File
@@ -8,6 +8,7 @@ Usage:
hanzo iam <subcommand>
hanzo kms <subcommand>
hanzo paas <subcommand>
hanzo s3 <subcommand>
hanzo k8s <subcommand | kubectl args>
"""
@@ -124,12 +125,14 @@ from hanzo_cli.iam.commands import iam # noqa: E402
from hanzo_cli.k8s.commands import k8s # noqa: E402
from hanzo_cli.kms.commands import kms # noqa: E402
from hanzo_cli.paas.commands import deploy, paas # noqa: E402
from hanzo_cli.s3.commands import s3 # noqa: E402
main.add_command(bot)
main.add_command(iam)
main.add_command(k8s)
main.add_command(kms)
main.add_command(paas)
main.add_command(s3)
# Top-level aliases — `hanzo deploy` = `hanzo paas deploy`
main.add_command(deploy)
+5
View File
@@ -0,0 +1,5 @@
"""Hanzo CLI — S3 object-storage subcommands."""
from hanzo_cli.s3.commands import s3
__all__ = ["s3"]
+156
View File
@@ -0,0 +1,156 @@
"""Hanzo CLI — S3 subcommands for object storage.
Wraps the ``hanzo_s3`` client (S3-compatible / MinIO) at s3.hanzo.ai.
Credentials come from the environment — inject them from KMS, never store
them in plaintext:
eval "$(hanzo kms inject myproject production)" # exports HANZO_S3_*
Usage:
hanzo s3 buckets — List buckets
hanzo s3 mb <bucket> — Make a bucket
hanzo s3 rb <bucket> [--yes] — Remove a bucket
hanzo s3 ls <bucket> [--prefix p] — List objects
hanzo s3 stat <bucket> <object> — Object metadata
hanzo s3 rm <bucket> <object> [--yes] — Remove an object
hanzo s3 presign <bucket> <object> — Presigned GET URL
"""
from __future__ import annotations
import os
import click
from rich.console import Console
from rich.table import Table
console = Console()
def _get_s3_client():
"""Build an S3 client from environment credentials."""
from hanzo_s3 import S3Client
endpoint = os.getenv("HANZO_S3_ENDPOINT", "s3.hanzo.ai")
access_key = os.getenv("HANZO_S3_ACCESS_KEY") or os.getenv("AWS_ACCESS_KEY_ID")
secret_key = os.getenv("HANZO_S3_SECRET_KEY") or os.getenv("AWS_SECRET_ACCESS_KEY")
secure = os.getenv("HANZO_S3_SECURE", "true").lower() not in ("0", "false", "no")
if not access_key or not secret_key:
raise click.ClickException(
"Missing S3 credentials. Set HANZO_S3_ACCESS_KEY and HANZO_S3_SECRET_KEY "
"(inject from KMS: eval \"$(hanzo kms inject <project> <env>)\")."
)
return S3Client(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
@click.group()
def s3() -> None:
"""Manage S3-compatible object storage."""
@s3.command("buckets")
def list_buckets() -> None:
"""List all buckets."""
client = _get_s3_client()
buckets = list(client.list_buckets())
if not buckets:
console.print("[yellow]No buckets found.[/yellow]")
return
table = Table(title="Buckets")
table.add_column("Name", style="cyan")
table.add_column("Created", style="white")
for b in buckets:
table.add_row(b.name, str(b.creation_date)[:19] if b.creation_date else "—")
console.print(table)
@s3.command("mb")
@click.argument("bucket")
def make_bucket(bucket: str) -> None:
"""Make a bucket."""
client = _get_s3_client()
client.make_bucket(bucket)
console.print(f"[green]Created[/green] bucket {bucket}")
@s3.command("rb")
@click.argument("bucket")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
def remove_bucket(bucket: str, yes: bool) -> None:
"""Remove a bucket (must be empty)."""
if not yes:
click.confirm(f"Remove bucket '{bucket}'?", abort=True)
client = _get_s3_client()
client.remove_bucket(bucket)
console.print(f"[green]Removed[/green] bucket {bucket}")
@s3.command("ls")
@click.argument("bucket")
@click.option("--prefix", default="", help="Object key prefix.")
@click.option("--recursive", "-r", is_flag=True, help="Recurse into prefixes.")
def list_objects(bucket: str, prefix: str, recursive: bool) -> None:
"""List objects in a bucket."""
client = _get_s3_client()
objects = list(client.list_objects(bucket, prefix=prefix, recursive=recursive))
if not objects:
console.print("[yellow]No objects found.[/yellow]")
return
table = Table(title=f"Objects: {bucket}/{prefix}")
table.add_column("Key", style="cyan")
table.add_column("Size", style="white", justify="right")
table.add_column("Modified", style="white")
for o in objects:
size = "—" if o.is_dir else str(o.size)
modified = str(o.last_modified)[:19] if o.last_modified else "—"
table.add_row(o.object_name, size, modified)
console.print(table)
@s3.command("stat")
@click.argument("bucket")
@click.argument("object_name")
def stat_object(bucket: str, object_name: str) -> None:
"""Show object metadata."""
client = _get_s3_client()
obj = client.stat_object(bucket, object_name)
table = Table(title=f"{bucket}/{object_name}")
table.add_column("Field", style="cyan")
table.add_column("Value", style="white")
table.add_row("Size", str(obj.size))
table.add_row("ETag", obj.etag or "—")
table.add_row("Content-Type", obj.content_type or "—")
table.add_row("Modified", str(obj.last_modified)[:19] if obj.last_modified else "—")
console.print(table)
@s3.command("rm")
@click.argument("bucket")
@click.argument("object_name")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
def remove_object(bucket: str, object_name: str, yes: bool) -> None:
"""Remove an object."""
if not yes:
click.confirm(f"Remove '{bucket}/{object_name}'?", abort=True)
client = _get_s3_client()
client.remove_object(bucket, object_name)
console.print(f"[green]Removed[/green] {bucket}/{object_name}")
@s3.command("presign")
@click.argument("bucket")
@click.argument("object_name")
@click.option("--expires", default=3600, help="URL lifetime in seconds.")
def presign(bucket: str, object_name: str, expires: int) -> None:
"""Generate a presigned GET URL for an object."""
from datetime import timedelta
client = _get_s3_client()
url = client.presigned_get_object(
bucket, object_name, expires=timedelta(seconds=expires)
)
click.echo(url)
+2 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "hanzo-cli"
version = "0.2.1"
description = "Hanzo unified CLI — IAM, KMS, Deploy"
description = "Hanzo unified CLI — IAM, KMS, PaaS, S3, Deploy"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.12"
@@ -26,6 +26,7 @@ dependencies = [
"pyjwt>=2.8.0",
"hanzo-iam>=1.30.0",
"hanzo-kms>=1.0.0",
"hanzo-s3>=1.0.0",
"tomli>=2.0.0; python_version < '3.11'",
]
+77
View File
@@ -0,0 +1,77 @@
"""Unit tests for the `hanzo s3` CLI group (mocked S3 client)."""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from hanzo_cli.s3.commands import s3
def _patch_client(client):
return patch("hanzo_cli.s3.commands._get_s3_client", return_value=client)
def test_s3_group_help():
result = CliRunner().invoke(s3, ["--help"])
assert result.exit_code == 0
for cmd in ("buckets", "mb", "rb", "ls", "stat", "rm", "presign"):
assert cmd in result.output
def test_buckets_lists():
client = MagicMock()
b = MagicMock(creation_date=datetime(2026, 1, 1))
b.name = "hanzo-space"
client.list_buckets.return_value = [b]
with _patch_client(client):
result = CliRunner().invoke(s3, ["buckets"])
assert result.exit_code == 0
assert "hanzo-space" in result.output
def test_make_bucket():
client = MagicMock()
with _patch_client(client):
result = CliRunner().invoke(s3, ["mb", "b1"])
assert result.exit_code == 0
client.make_bucket.assert_called_once_with("b1")
def test_ls_objects():
client = MagicMock()
obj = MagicMock(object_name="a.txt", size=12, is_dir=False,
last_modified=datetime(2026, 1, 1))
client.list_objects.return_value = [obj]
with _patch_client(client):
result = CliRunner().invoke(s3, ["ls", "b1", "--prefix", "a"])
assert result.exit_code == 0
assert "a.txt" in result.output
def test_presign_outputs_url():
client = MagicMock()
client.presigned_get_object.return_value = "https://s3.hanzo.ai/b1/a.txt?sig=x"
with _patch_client(client):
result = CliRunner().invoke(s3, ["presign", "b1", "a.txt"])
assert result.exit_code == 0
assert "https://s3.hanzo.ai/" in result.output
def test_rm_confirms(monkeypatch):
client = MagicMock()
with _patch_client(client):
# Decline confirmation → aborts, client not called.
result = CliRunner().invoke(s3, ["rm", "b1", "a.txt"], input="n\n")
assert result.exit_code != 0
client.remove_object.assert_not_called()
def test_missing_credentials():
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(s3, ["buckets"])
assert result.exit_code != 0
assert "credentials" in result.output.lower()
@@ -60,6 +60,7 @@ PACKAGE_TOOL_PREFIXES: dict[str, list[str]] = {
"auth": ["auth"],
"kms": ["kms"],
"paas": ["paas"],
"s3": ["s3"],
"billing": ["billing"],
"commerce": ["commerce"],
"iam_tools": ["iam"],
+15
View File
@@ -0,0 +1,15 @@
# hanzo-tools-s3
MCP tool for Hanzo S3 — S3-compatible object storage (s3.hanzo.ai).
Exposes a single `s3` tool with bucket and object actions: `buckets`,
`make_bucket`, `remove_bucket`, `objects`, `stat`, `remove_object`, `presign`.
Credentials come from the environment — inject them from KMS, never plaintext:
```bash
eval "$(hanzo kms inject <project> <env>)" # exports HANZO_S3_*
```
Env: `HANZO_S3_ENDPOINT` (default `s3.hanzo.ai`), `HANZO_S3_ACCESS_KEY`,
`HANZO_S3_SECRET_KEY`, `HANZO_S3_SECURE` (default `true`).
@@ -0,0 +1,7 @@
"""Hanzo S3 Tools — object storage via MCP."""
from .s3_tool import S3Tool
TOOLS = [S3Tool]
__all__ = ["S3Tool", "TOOLS"]
@@ -0,0 +1,244 @@
"""MCP tool for Hanzo S3 — S3-compatible object storage.
Single tool over the ``hanzo_s3`` client (MinIO-compatible) at s3.hanzo.ai.
Buckets and objects: list, create, remove, stat, and presign.
Auth: credentials from the environment — inject them from KMS, never store
plaintext:
eval "$(hanzo kms inject <project> <env>)" # exports HANZO_S3_*
"""
from __future__ import annotations
import os
import json
import logging
from typing import Any, Annotated, final
from datetime import timedelta
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core.base import BaseTool
logger = logging.getLogger(__name__)
DESCRIPTION = """Hanzo S3 — S3-compatible object storage (s3.hanzo.ai).
Credentials from env: HANZO_S3_ENDPOINT, HANZO_S3_ACCESS_KEY, HANZO_S3_SECRET_KEY
(inject from KMS — never plaintext).
Bucket actions:
- buckets: List buckets
- make_bucket: Create a bucket (params: bucket)
- remove_bucket: Remove an empty bucket (params: bucket)
Object actions:
- objects: List objects (params: bucket, prefix, recursive)
- stat: Object metadata (params: bucket, object_name)
- remove_object: Delete an object (params: bucket, object_name)
- presign: Presigned GET URL (params: bucket, object_name, expires)
"""
def _get_client():
"""Build an S3 client from environment credentials."""
from hanzo_s3 import S3Client
endpoint = os.getenv("HANZO_S3_ENDPOINT", "s3.hanzo.ai")
access_key = os.getenv("HANZO_S3_ACCESS_KEY") or os.getenv("AWS_ACCESS_KEY_ID")
secret_key = os.getenv("HANZO_S3_SECRET_KEY") or os.getenv("AWS_SECRET_ACCESS_KEY")
secure = os.getenv("HANZO_S3_SECURE", "true").lower() not in ("0", "false", "no")
if not access_key or not secret_key:
raise RuntimeError(
"Missing S3 credentials. Set HANZO_S3_ACCESS_KEY and HANZO_S3_SECRET_KEY "
"(inject from KMS)."
)
return S3Client(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
@final
class S3Tool(BaseTool):
"""MCP tool for Hanzo S3 object storage."""
@property
def name(self) -> str:
return "s3"
@property
def description(self) -> str:
return DESCRIPTION
async def call(
self,
ctx: MCPContext,
action: str = "buckets",
bucket: str | None = None,
object_name: str | None = None,
prefix: str = "",
recursive: bool = False,
expires: int = 3600,
**kwargs: Any,
) -> str:
try:
if action == "buckets":
return self._buckets()
elif action == "make_bucket":
return self._make_bucket(bucket)
elif action == "remove_bucket":
return self._remove_bucket(bucket)
elif action == "objects":
return self._objects(bucket, prefix, recursive)
elif action == "stat":
return self._stat(bucket, object_name)
elif action == "remove_object":
return self._remove_object(bucket, object_name)
elif action == "presign":
return self._presign(bucket, object_name, expires)
else:
return json.dumps({
"error": f"Unknown action: {action}",
"available": [
"buckets", "make_bucket", "remove_bucket",
"objects", "stat", "remove_object", "presign",
],
})
except RuntimeError as e:
return json.dumps({"error": str(e)})
except Exception as e:
logger.exception(f"S3 tool error: {e}")
return json.dumps({"error": f"S3 error: {e}"})
# -- Bucket actions ------------------------------------------------------
def _buckets(self) -> str:
client = _get_client()
result = [
{
"name": b.name,
"creation_date": str(b.creation_date) if b.creation_date else None,
}
for b in client.list_buckets()
]
return json.dumps({"count": len(result), "buckets": result}, indent=2)
def _make_bucket(self, bucket: str | None) -> str:
if not bucket:
return json.dumps({"error": "Required: bucket"})
client = _get_client()
client.make_bucket(bucket)
return json.dumps({"action": "created", "bucket": bucket}, indent=2)
def _remove_bucket(self, bucket: str | None) -> str:
if not bucket:
return json.dumps({"error": "Required: bucket"})
client = _get_client()
client.remove_bucket(bucket)
return json.dumps({"action": "removed", "bucket": bucket}, indent=2)
# -- Object actions ------------------------------------------------------
def _objects(self, bucket: str | None, prefix: str, recursive: bool) -> str:
if not bucket:
return json.dumps({"error": "Required: bucket"})
client = _get_client()
result = [
{
"key": o.object_name,
"size": None if o.is_dir else o.size,
"last_modified": str(o.last_modified) if o.last_modified else None,
"is_dir": o.is_dir,
}
for o in client.list_objects(bucket, prefix=prefix, recursive=recursive)
]
return json.dumps(
{"bucket": bucket, "prefix": prefix, "count": len(result), "objects": result},
indent=2,
)
def _stat(self, bucket: str | None, object_name: str | None) -> str:
if not bucket or not object_name:
return json.dumps({"error": "Required: bucket, object_name"})
client = _get_client()
obj = client.stat_object(bucket, object_name)
return json.dumps({
"bucket": bucket,
"object_name": object_name,
"size": obj.size,
"etag": obj.etag,
"content_type": obj.content_type,
"last_modified": str(obj.last_modified) if obj.last_modified else None,
}, indent=2)
def _remove_object(self, bucket: str | None, object_name: str | None) -> str:
if not bucket or not object_name:
return json.dumps({"error": "Required: bucket, object_name"})
client = _get_client()
client.remove_object(bucket, object_name)
return json.dumps({"action": "removed", "bucket": bucket, "object_name": object_name}, indent=2)
def _presign(self, bucket: str | None, object_name: str | None, expires: int) -> str:
if not bucket or not object_name:
return json.dumps({"error": "Required: bucket, object_name"})
client = _get_client()
url = client.presigned_get_object(
bucket, object_name, expires=timedelta(seconds=expires)
)
return json.dumps({"bucket": bucket, "object_name": object_name, "url": url}, indent=2)
# -- Registration --------------------------------------------------------
def register(self, mcp_server: FastMCP) -> None:
"""Register S3 tool with explicit parameters."""
tool_instance = self
@mcp_server.tool(
name="s3",
description=DESCRIPTION,
)
async def s3(
action: Annotated[
str,
Field(
description=(
"Action to perform. "
"Buckets: buckets, make_bucket, remove_bucket. "
"Objects: objects, stat, remove_object, presign."
),
),
] = "buckets",
bucket: Annotated[
str | None,
Field(description="Bucket name"),
] = None,
object_name: Annotated[
str | None,
Field(description="Object key (for stat, remove_object, presign)"),
] = None,
prefix: Annotated[
str,
Field(description="Object key prefix (for objects)"),
] = "",
recursive: Annotated[
bool,
Field(description="Recurse into prefixes (for objects)"),
] = False,
expires: Annotated[
int,
Field(description="Presigned URL lifetime in seconds (for presign)"),
] = 3600,
ctx: MCPContext = None,
) -> str:
return await tool_instance.call(
ctx,
action=action,
bucket=bucket,
object_name=object_name,
prefix=prefix,
recursive=recursive,
expires=expires,
)
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "hanzo-tools-s3"
version = "0.1.0"
description = "Hanzo S3 MCP tool — buckets and objects on S3-compatible storage"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo AI", email = "[email protected]" }]
keywords = ["hanzo", "mcp", "s3", "minio", "object-storage", "tools"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"hanzo-tools-core>=0.1.0",
"hanzo-s3>=1.0.0",
]
[project.entry-points."hanzo.tools"]
s3 = "hanzo_tools.s3:TOOLS"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["hanzo_tools"]
+138
View File
@@ -0,0 +1,138 @@
"""S3Tool test suite — action routing, validation, client wiring."""
import json
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from hanzo_tools.s3.s3_tool import S3Tool
@pytest.fixture
def tool():
return S3Tool()
@pytest.fixture
def ctx():
return MagicMock()
def _patch_client(client):
return patch("hanzo_tools.s3.s3_tool._get_client", return_value=client)
class TestProperties:
def test_name(self, tool):
assert tool.name == "s3"
def test_description_sections(self, tool):
for section in ("Bucket actions", "Object actions"):
assert section in tool.description
class TestActionRouting:
@pytest.mark.asyncio
async def test_unknown_action_returns_error(self, tool, ctx):
result = json.loads(await tool.call(ctx, action="bogus"))
assert "Unknown action" in result["error"]
@pytest.mark.asyncio
async def test_unknown_action_lists_available(self, tool, ctx):
result = json.loads(await tool.call(ctx, action="bogus"))
assert set(result["available"]) == {
"buckets", "make_bucket", "remove_bucket",
"objects", "stat", "remove_object", "presign",
}
class TestBuckets:
@pytest.mark.asyncio
async def test_buckets(self, tool, ctx):
client = MagicMock()
bucket = MagicMock(name="hanzo-space", creation_date=datetime(2026, 1, 1))
bucket.name = "hanzo-space"
client.list_buckets.return_value = [bucket]
with _patch_client(client):
result = json.loads(await tool.call(ctx, action="buckets"))
assert result["count"] == 1
assert result["buckets"][0]["name"] == "hanzo-space"
@pytest.mark.asyncio
async def test_make_bucket_requires_name(self, tool, ctx):
result = json.loads(await tool.call(ctx, action="make_bucket"))
assert "Required" in result["error"]
@pytest.mark.asyncio
async def test_make_bucket(self, tool, ctx):
client = MagicMock()
with _patch_client(client):
result = json.loads(await tool.call(ctx, action="make_bucket", bucket="b1"))
client.make_bucket.assert_called_once_with("b1")
assert result["action"] == "created"
@pytest.mark.asyncio
async def test_remove_bucket(self, tool, ctx):
client = MagicMock()
with _patch_client(client):
result = json.loads(await tool.call(ctx, action="remove_bucket", bucket="b1"))
client.remove_bucket.assert_called_once_with("b1")
assert result["action"] == "removed"
class TestObjects:
@pytest.mark.asyncio
async def test_objects_requires_bucket(self, tool, ctx):
result = json.loads(await tool.call(ctx, action="objects"))
assert "Required" in result["error"]
@pytest.mark.asyncio
async def test_objects(self, tool, ctx):
client = MagicMock()
obj = MagicMock(object_name="a.txt", size=12, is_dir=False,
last_modified=datetime(2026, 1, 1))
client.list_objects.return_value = [obj]
with _patch_client(client):
result = json.loads(await tool.call(ctx, action="objects", bucket="b1", prefix="a"))
assert result["count"] == 1
assert result["objects"][0]["key"] == "a.txt"
client.list_objects.assert_called_once_with("b1", prefix="a", recursive=False)
@pytest.mark.asyncio
async def test_stat(self, tool, ctx):
client = MagicMock()
client.stat_object.return_value = MagicMock(
size=99, etag="abc", content_type="text/plain",
last_modified=datetime(2026, 1, 1),
)
with _patch_client(client):
result = json.loads(await tool.call(ctx, action="stat", bucket="b1", object_name="a.txt"))
assert result["size"] == 99
assert result["etag"] == "abc"
@pytest.mark.asyncio
async def test_remove_object(self, tool, ctx):
client = MagicMock()
with _patch_client(client):
result = json.loads(await tool.call(ctx, action="remove_object", bucket="b1", object_name="a.txt"))
client.remove_object.assert_called_once_with("b1", "a.txt")
assert result["action"] == "removed"
@pytest.mark.asyncio
async def test_presign(self, tool, ctx):
client = MagicMock()
client.presigned_get_object.return_value = "https://s3.hanzo.ai/b1/a.txt?sig=x"
with _patch_client(client):
result = json.loads(await tool.call(ctx, action="presign", bucket="b1", object_name="a.txt"))
assert result["url"].startswith("https://s3.hanzo.ai/")
class TestCredentials:
@pytest.mark.asyncio
async def test_missing_creds_errors(self, tool, ctx, monkeypatch):
for var in ("HANZO_S3_ACCESS_KEY", "HANZO_S3_SECRET_KEY",
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"):
monkeypatch.delenv(var, raising=False)
result = json.loads(await tool.call(ctx, action="buckets"))
assert "credentials" in result["error"].lower()
+11
View File
@@ -486,6 +486,10 @@ members = [
"pkg/hanzo-s3",
"pkg/hanzo-tools-ui",
"pkg/hanzo-tools-browser",
"pkg/hanzo-cli",
"pkg/hanzo-iam",
"pkg/hanzo-kms",
"pkg/hanzo-tools-s3",
]
[tool.uv.sources]
@@ -494,6 +498,13 @@ hanzo-tools-ui = { workspace = true }
# Browser tool routes through the local zapd router; the on-disk source is the
# single truth (not a published wheel) so the MCP and the repo never diverge.
hanzo-tools-browser = { workspace = true }
# Unified CLI + its service clients resolve to the on-disk source, never a
# stale published wheel — one truth for the control surface.
hanzo-cli = { workspace = true }
hanzo-iam = { workspace = true }
hanzo-kms = { workspace = true }
hanzo-s3 = { workspace = true }
hanzo-tools-s3 = { workspace = true }
[tool.ruff.lint.extend-per-file-ignores]
# Allow print statements in scripts and examples
Generated
+171
View File
@@ -9,9 +9,13 @@ resolution-markers = [
[manifest]
members = [
"hanzo-cli",
"hanzo-iam",
"hanzo-kms",
"hanzo-memory",
"hanzo-s3",
"hanzo-tools-browser",
"hanzo-tools-s3",
"hanzo-tools-ui",
"hanzoai",
]
@@ -1221,6 +1225,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
[[package]]
name = "hanzo-cli"
version = "0.2.1"
source = { editable = "pkg/hanzo-cli" }
dependencies = [
{ name = "click" },
{ name = "hanzo-iam" },
{ name = "hanzo-kms" },
{ name = "hanzo-s3" },
{ name = "httpx" },
{ name = "pyjwt" },
{ name = "rich" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.0" },
{ name = "hanzo-iam", editable = "pkg/hanzo-iam" },
{ name = "hanzo-kms", editable = "pkg/hanzo-kms" },
{ name = "hanzo-s3", editable = "pkg/hanzo-s3" },
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "pyjwt", specifier = ">=2.8.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "rich", specifier = ">=13.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" },
]
provides-extras = ["dev"]
[[package]]
name = "hanzo-iam"
version = "1.30.0"
source = { editable = "pkg/hanzo-iam" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pyjwt" },
]
[package.optional-dependencies]
dev = [
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
fastapi = [
{ name = "fastapi" },
]
kms = [
{ name = "hanzo-kms" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" },
{ name = "hanzo-kms", marker = "extra == 'kms'", editable = "pkg/hanzo-kms" },
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pyjwt", specifier = ">=2.8.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
]
provides-extras = ["fastapi", "kms", "dev"]
[[package]]
name = "hanzo-kms"
version = "1.1.0"
source = { editable = "pkg/hanzo-kms" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
]
[package.optional-dependencies]
async = [
{ name = "httpx", extra = ["http2"] },
]
dev = [
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "httpx", extras = ["http2"], marker = "extra == 'async'", specifier = ">=0.25.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
]
provides-extras = ["async", "dev"]
[[package]]
name = "hanzo-llm"
version = "1.81.15"
@@ -1375,6 +1497,7 @@ dependencies = [
{ name = "hanzo-tools" },
{ name = "mcp" },
{ name = "pydantic" },
{ name = "zap-proto" },
]
[package.optional-dependencies]
@@ -1388,6 +1511,7 @@ requires-dist = [
{ name = "mcp", specifier = ">=1.25.0" },
{ name = "playwright", marker = "extra == 'playwright'", specifier = ">=1.49.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "zap-proto", specifier = ">=1.2.0" },
]
provides-extras = ["playwright"]
@@ -1403,6 +1527,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f2/a0bdbc43b160a32dae34eac683779a5aa70d326665b758a4972101fb0b44/hanzo_tools_core-0.3.0-py3-none-any.whl", hash = "sha256:75bba9a3fb6f9203e9bd6c14fff86fdebbd50b0b7b44f6fb663ddfccbba25bf3", size = 18252, upload-time = "2026-02-21T19:58:29.008Z" },
]
[[package]]
name = "hanzo-tools-s3"
version = "0.1.0"
source = { editable = "pkg/hanzo-tools-s3" }
dependencies = [
{ name = "hanzo-s3" },
{ name = "hanzo-tools-core" },
]
[package.metadata]
requires-dist = [
{ name = "hanzo-s3", editable = "pkg/hanzo-s3" },
{ name = "hanzo-tools-core", specifier = ">=0.1.0" },
]
[[package]]
name = "hanzo-tools-ui"
version = "0.2.0"
@@ -1549,6 +1688,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" },
]
[[package]]
name = "hpack"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -1606,6 +1754,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
http2 = [
{ name = "h2" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
@@ -1644,6 +1797,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "id"
version = "1.6.1"
@@ -4537,6 +4699,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
]
[[package]]
name = "zap-proto"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/7b/37543decb0ec4d55781307a5a00a52e974f0a114cc9b042dfd42b0a57154/zap_proto-1.4.1.tar.gz", hash = "sha256:32965a0c1d10c6532b294e531497e519292bf63638c00f1d83211268330777c7", size = 99806, upload-time = "2026-06-21T15:04:23.967Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/eb/bec558b662a24149d8a266b34bb655e6ce08c08118cd2bb2aa866b4e5421/zap_proto-1.4.1-py3-none-any.whl", hash = "sha256:21b440ed1c5d265edc5fa267dc88b042f240b7a2e8b8509aaec1d8a3fb41fea6", size = 61625, upload-time = "2026-06-21T15:04:22.871Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"