mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
313 lines
11 KiB
Python
313 lines
11 KiB
Python
"""Local filesystem client for reading UI components from ~/work/hanzo/ui.
|
|
|
|
When the hanzo/ui repo is cloned locally, this client reads component source
|
|
directly from disk — no GitHub API calls, no rate limits, always up-to-date
|
|
with the developer's working tree.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import aiofiles
|
|
|
|
# Default local repo path; overridable via HANZO_UI_PATH env var
|
|
DEFAULT_UI_PATH = os.path.expanduser("~/work/hanzo/ui")
|
|
|
|
# Local package layout — maps to the actual repo structure
|
|
LOCAL_PACKAGES: dict[str, dict[str, str]] = {
|
|
"ui": {
|
|
"primitives": "pkg/ui/primitives",
|
|
"src": "pkg/ui/src",
|
|
"blocks": "pkg/ui/primitives", # blocks live alongside primitives
|
|
"style": "pkg/ui/style",
|
|
"docs": "pkg/ui/docs",
|
|
"util": "pkg/ui/util",
|
|
},
|
|
"react": {
|
|
"components": "pkg/react/src/components",
|
|
"hooks": "pkg/react/src/hooks",
|
|
},
|
|
"commerce": {
|
|
"components": "pkg/commerce/components",
|
|
},
|
|
"brand": {
|
|
"root": "pkg/brand",
|
|
},
|
|
"gui": {
|
|
"root": "pkg/gui",
|
|
},
|
|
"shop": {
|
|
"root": "pkg/shop",
|
|
},
|
|
"checkout": {
|
|
"root": "pkg/checkout",
|
|
},
|
|
"agent-ui": {
|
|
"root": "pkg/agent-ui",
|
|
},
|
|
"tokens": {
|
|
"root": "pkg/tokens",
|
|
},
|
|
}
|
|
|
|
# Extensions to search for components
|
|
COMPONENT_EXTENSIONS = (".tsx", ".ts", ".jsx", ".js")
|
|
|
|
|
|
class LocalUIClient:
|
|
"""Reads Hanzo UI components from the local filesystem."""
|
|
|
|
def __init__(self, ui_path: str | None = None):
|
|
self._ui_path = Path(ui_path or os.environ.get("HANZO_UI_PATH", DEFAULT_UI_PATH))
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
"""Check if the local UI repo exists."""
|
|
return (self._ui_path / "pkg" / "ui").is_dir()
|
|
|
|
async def list_components(self, framework: str = "hanzo") -> list[dict]:
|
|
"""List all components from the local primitives directory."""
|
|
primitives_dir = self._ui_path / "pkg" / "ui" / "primitives"
|
|
if not primitives_dir.is_dir():
|
|
return []
|
|
|
|
components = []
|
|
for entry in sorted(primitives_dir.iterdir()):
|
|
if entry.name.startswith(("_", ".")):
|
|
continue
|
|
if entry.name.startswith("index"):
|
|
continue
|
|
if entry.is_dir():
|
|
components.append(
|
|
{
|
|
"name": entry.name,
|
|
"type": "dir",
|
|
"path": str(entry.relative_to(self._ui_path)),
|
|
}
|
|
)
|
|
elif entry.is_file() and entry.suffix in COMPONENT_EXTENSIONS:
|
|
components.append(
|
|
{
|
|
"name": entry.stem,
|
|
"type": "file",
|
|
"path": str(entry.relative_to(self._ui_path)),
|
|
}
|
|
)
|
|
return components
|
|
|
|
async def fetch_component(self, name: str, framework: str = "hanzo") -> str:
|
|
"""Fetch component source code from local filesystem."""
|
|
# Search in primitives first, then src modules
|
|
search_dirs = [
|
|
self._ui_path / "pkg" / "ui" / "primitives",
|
|
self._ui_path / "pkg" / "ui" / "src",
|
|
self._ui_path / "pkg" / "react" / "src" / "components",
|
|
]
|
|
|
|
for base_dir in search_dirs:
|
|
if not base_dir.is_dir():
|
|
continue
|
|
# Try direct file match
|
|
for ext in COMPONENT_EXTENSIONS:
|
|
candidate = base_dir / f"{name}{ext}"
|
|
if candidate.is_file():
|
|
async with aiofiles.open(candidate) as f:
|
|
return await f.read()
|
|
|
|
# Try directory with index file
|
|
comp_dir = base_dir / name
|
|
if comp_dir.is_dir():
|
|
for ext in COMPONENT_EXTENSIONS:
|
|
index = comp_dir / f"index{ext}"
|
|
if index.is_file():
|
|
async with aiofiles.open(index) as f:
|
|
return await f.read()
|
|
|
|
# Search in src barrel exports
|
|
barrel = self._ui_path / "pkg" / "ui" / "src" / f"{name}.ts"
|
|
if barrel.is_file():
|
|
async with aiofiles.open(barrel) as f:
|
|
return await f.read()
|
|
|
|
raise FileNotFoundError(f"Component '{name}' not found locally in hanzo/ui")
|
|
|
|
async def fetch_component_demo(self, name: str, framework: str = "hanzo") -> str:
|
|
"""Fetch component demo from local filesystem."""
|
|
# Check demo/ app directory
|
|
demo_dirs = [
|
|
self._ui_path / "demo",
|
|
self._ui_path / "apps",
|
|
]
|
|
for demo_dir in demo_dirs:
|
|
if not demo_dir.is_dir():
|
|
continue
|
|
for ext in COMPONENT_EXTENSIONS:
|
|
candidate = demo_dir / f"{name}{ext}"
|
|
if candidate.is_file():
|
|
async with aiofiles.open(candidate) as f:
|
|
return await f.read()
|
|
|
|
# Check for demo file alongside component
|
|
primitives_dir = self._ui_path / "pkg" / "ui" / "primitives"
|
|
comp_dir = primitives_dir / name
|
|
if comp_dir.is_dir():
|
|
for ext in COMPONENT_EXTENSIONS:
|
|
demo = comp_dir / f"demo{ext}"
|
|
if demo.is_file():
|
|
async with aiofiles.open(demo) as f:
|
|
return await f.read()
|
|
|
|
raise FileNotFoundError(f"Demo for '{name}' not found locally")
|
|
|
|
async def fetch_component_metadata(self, name: str, framework: str = "hanzo") -> dict:
|
|
"""Fetch component metadata from local filesystem."""
|
|
primitives_dir = self._ui_path / "pkg" / "ui" / "primitives"
|
|
|
|
# Check for metadata.json in component directory
|
|
meta_path = primitives_dir / name / "metadata.json"
|
|
if meta_path.is_file():
|
|
async with aiofiles.open(meta_path) as f:
|
|
content = await f.read()
|
|
return json.loads(content)
|
|
|
|
# Build metadata from file info
|
|
for ext in COMPONENT_EXTENSIONS:
|
|
comp_file = primitives_dir / f"{name}{ext}"
|
|
if comp_file.is_file():
|
|
stat = comp_file.stat()
|
|
return {
|
|
"name": name,
|
|
"framework": "hanzo",
|
|
"path": str(comp_file.relative_to(self._ui_path)),
|
|
"size": stat.st_size,
|
|
"extension": ext,
|
|
"source": "local",
|
|
}
|
|
|
|
return {"name": name, "framework": "hanzo", "source": "local"}
|
|
|
|
async def list_blocks(self, framework: str = "hanzo") -> list[dict]:
|
|
"""List blocks from local filesystem."""
|
|
# Check index-blocks.ts for block exports
|
|
blocks_index = self._ui_path / "pkg" / "ui" / "primitives" / "index-blocks.ts"
|
|
if blocks_index.is_file():
|
|
async with aiofiles.open(blocks_index) as f:
|
|
content = await f.read()
|
|
blocks = []
|
|
for line in content.splitlines():
|
|
if line.strip().startswith("export"):
|
|
# Extract component names from export lines
|
|
name = line.split("from")[-1].strip().strip("';\"").split("/")[-1]
|
|
if name:
|
|
blocks.append({"name": name, "type": "export"})
|
|
return blocks
|
|
return []
|
|
|
|
async def fetch_block(self, name: str, framework: str = "hanzo") -> str:
|
|
"""Fetch block source code."""
|
|
return await self.fetch_component(name, framework)
|
|
|
|
async def get_directory_structure(self, path: str, framework: str = "hanzo") -> dict:
|
|
"""Browse directory structure of the local UI repo."""
|
|
if path:
|
|
target = self._ui_path / path
|
|
else:
|
|
target = self._ui_path / "pkg"
|
|
|
|
if not target.is_dir():
|
|
raise FileNotFoundError(f"Directory not found: {path}")
|
|
|
|
children = []
|
|
for entry in sorted(target.iterdir()):
|
|
if entry.name.startswith(".") or entry.name == "node_modules":
|
|
continue
|
|
item: dict[str, Any] = {
|
|
"name": entry.name,
|
|
"type": "dir" if entry.is_dir() else "file",
|
|
"path": str(entry.relative_to(self._ui_path)),
|
|
}
|
|
if entry.is_file():
|
|
item["size"] = entry.stat().st_size
|
|
children.append(item)
|
|
|
|
return {"path": path or "pkg/", "children": children}
|
|
|
|
async def list_packages(self) -> list[dict]:
|
|
"""List all UI packages available locally."""
|
|
pkg_dir = self._ui_path / "pkg"
|
|
if not pkg_dir.is_dir():
|
|
return []
|
|
|
|
packages = []
|
|
for entry in sorted(pkg_dir.iterdir()):
|
|
if not entry.is_dir() or entry.name.startswith("."):
|
|
continue
|
|
pkg_json = entry / "package.json"
|
|
info: dict[str, Any] = {
|
|
"name": entry.name,
|
|
"path": str(entry.relative_to(self._ui_path)),
|
|
}
|
|
if pkg_json.is_file():
|
|
async with aiofiles.open(pkg_json) as f:
|
|
try:
|
|
data = json.loads(await f.read())
|
|
info["version"] = data.get("version", "unknown")
|
|
info["description"] = data.get("description", "")
|
|
except json.JSONDecodeError:
|
|
pass
|
|
packages.append(info)
|
|
return packages
|
|
|
|
async def read_file(self, path: str) -> str:
|
|
"""Read any file from the UI repo by relative path."""
|
|
target = self._ui_path / path
|
|
if not target.is_file():
|
|
raise FileNotFoundError(f"File not found: {path}")
|
|
async with aiofiles.open(target) as f:
|
|
return await f.read()
|
|
|
|
async def search_components(self, query: str) -> list[dict]:
|
|
"""Search component files by name and content."""
|
|
query_lower = query.lower()
|
|
results = []
|
|
|
|
primitives_dir = self._ui_path / "pkg" / "ui" / "primitives"
|
|
if not primitives_dir.is_dir():
|
|
return results
|
|
|
|
for entry in sorted(primitives_dir.iterdir()):
|
|
if entry.name.startswith(("_", ".")) or entry.name.startswith("index"):
|
|
continue
|
|
|
|
name = entry.stem if entry.is_file() else entry.name
|
|
if query_lower in name.lower():
|
|
results.append(
|
|
{
|
|
"name": name,
|
|
"type": "dir" if entry.is_dir() else "file",
|
|
"path": str(entry.relative_to(self._ui_path)),
|
|
"match": "name",
|
|
}
|
|
)
|
|
|
|
# Also search src modules
|
|
src_dir = self._ui_path / "pkg" / "ui" / "src"
|
|
if src_dir.is_dir():
|
|
for entry in sorted(src_dir.iterdir()):
|
|
if entry.name.startswith(("_", ".")):
|
|
continue
|
|
name = entry.stem if entry.is_file() else entry.name
|
|
if query_lower in name.lower() and name not in [r["name"] for r in results]:
|
|
results.append(
|
|
{
|
|
"name": name,
|
|
"type": "dir" if entry.is_dir() else "file",
|
|
"path": str(entry.relative_to(self._ui_path)),
|
|
"match": "name",
|
|
}
|
|
)
|
|
|
|
return results
|