Files

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