mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
580 lines
21 KiB
Python
580 lines
21 KiB
Python
"""Unified UI component registry tool (HIP-0300).
|
|
|
|
Single 'ui' tool for browsing, searching, installing, and managing
|
|
UI components from Hanzo and other registries.
|
|
|
|
Backend priority: local disk → registry server → GitHub API
|
|
|
|
Actions:
|
|
- list_components: List available components for a framework
|
|
- get_component: Get component source code
|
|
- get_demo: Get component demo/example
|
|
- get_metadata: Get component metadata
|
|
- list_blocks: List UI blocks
|
|
- get_block: Get block implementation
|
|
- search: Search components by name/description
|
|
- get_structure: Browse repository directory structure
|
|
- install: Install component via CLI
|
|
- set_framework: Switch active framework
|
|
- get_framework: Show current and available frameworks
|
|
- create_composition: Scaffold a composition from components
|
|
- list_packages: List all local UI packages (local only)
|
|
- read_file: Read any file from the UI repo (local only)
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import ClassVar
|
|
|
|
from mcp.server.fastmcp import Context as MCPContext
|
|
|
|
from hanzo_tools.core import BaseTool
|
|
|
|
from .github_api import (
|
|
FRAMEWORK_CONFIGS,
|
|
FRAMEWORK_NAMES,
|
|
GitHubAPIClient,
|
|
)
|
|
from .local_client import LocalUIClient
|
|
from .registry.client import RegistryClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Module-level current framework state
|
|
_current_framework = "hanzo"
|
|
|
|
|
|
class UiTool(BaseTool):
|
|
"""UI component registry tool (HIP-0300).
|
|
|
|
Browse, search, install, and manage UI components from
|
|
Hanzo and other registries (shadcn/ui, Vue, Svelte, React Native).
|
|
|
|
Backend priority: local → registry → GitHub API
|
|
"""
|
|
|
|
name: ClassVar[str] = "ui"
|
|
VERSION: ClassVar[str] = "0.3.0"
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._github = GitHubAPIClient()
|
|
self._local = LocalUIClient()
|
|
self._registry = RegistryClient()
|
|
self._registry_checked = False
|
|
self._registry_available = False
|
|
|
|
if self._local.available:
|
|
logger.info("UI tool: local hanzo/ui repo detected, using local-first mode")
|
|
self._register_ui_actions()
|
|
|
|
def _use_local(self, framework: str) -> bool:
|
|
"""Use local client for hanzo frameworks when repo is available."""
|
|
return framework.startswith("hanzo") and self._local.available
|
|
|
|
async def _use_registry(self) -> bool:
|
|
"""Check if the registry server is available (cached after first check)."""
|
|
if not self._registry_checked:
|
|
self._registry_checked = True
|
|
self._registry_available = await self._registry.check_available()
|
|
if self._registry_available:
|
|
logger.info("UI tool: registry server available at %s", self._registry._base_url)
|
|
return self._registry_available
|
|
|
|
async def _remote_client(self):
|
|
"""Return registry client if available, else GitHub client."""
|
|
if await self._use_registry():
|
|
return self._registry, "registry"
|
|
return self._github, "github"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
local_status = " (local repo detected)" if self._local.available else ""
|
|
return f"""UI component registry tool (HIP-0300){local_status}.
|
|
|
|
Actions:
|
|
- list_components: List available components (framework, category)
|
|
- get_component: Get component source code (name, framework)
|
|
- get_demo: Get component demo/example (name, framework)
|
|
- get_metadata: Get component metadata (name, framework)
|
|
- list_blocks: List UI blocks (framework, category)
|
|
- get_block: Get block implementation (name, framework)
|
|
- search: Search components (query, framework)
|
|
- get_structure: Browse repo directory structure (path, framework)
|
|
- install: Install component via CLI (name, framework)
|
|
- set_framework: Switch active framework (framework)
|
|
- get_framework: Show current and available frameworks
|
|
- create_composition: Scaffold from components (name, components)
|
|
- list_packages: List all UI packages (hanzo local only)
|
|
- read_file: Read any file from the UI repo by path (hanzo local only)
|
|
- ask: Ask a question about UI components (RAG via Hanzo Cloud)
|
|
- semantic_search: Semantic search over components (Hanzo Cloud)
|
|
- index_status: Check search index status
|
|
- rebuild_index: Re-index components into Hanzo Cloud search
|
|
|
|
Frameworks: hanzo (default), hanzo-native, hanzo-vue, hanzo-svelte,
|
|
shadcn, react, svelte, vue, react-native
|
|
"""
|
|
|
|
def _fw_name(self, framework: str) -> str:
|
|
return FRAMEWORK_NAMES.get(framework, framework)
|
|
|
|
def _register_ui_actions(self):
|
|
global _current_framework
|
|
|
|
@self.action("list_components", "List available components")
|
|
async def list_components(
|
|
ctx: MCPContext,
|
|
framework: str | None = None,
|
|
category: str | None = None,
|
|
) -> dict:
|
|
fw = framework or _current_framework
|
|
if fw not in FRAMEWORK_CONFIGS:
|
|
return {
|
|
"error": f"Unknown framework: {fw}. Available: {', '.join(FRAMEWORK_CONFIGS)}"
|
|
}
|
|
|
|
if self._use_local(fw):
|
|
components = await self._local.list_components(fw)
|
|
source = "local"
|
|
else:
|
|
client, source = await self._remote_client()
|
|
components = await client.list_components(fw)
|
|
|
|
if category:
|
|
components = [c for c in components if c.get("category") == category]
|
|
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"source": source,
|
|
"total": len(components),
|
|
"components": components,
|
|
}
|
|
|
|
@self.action("get_component", "Get component source code")
|
|
async def get_component(
|
|
ctx: MCPContext,
|
|
name: str | None = None,
|
|
component: str | None = None,
|
|
framework: str | None = None,
|
|
) -> dict:
|
|
comp_name = name or component
|
|
if not comp_name:
|
|
return {"error": "Component name is required"}
|
|
|
|
fw = framework or _current_framework
|
|
|
|
if self._use_local(fw):
|
|
try:
|
|
source = await self._local.fetch_component(comp_name, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"component": comp_name,
|
|
"source": source,
|
|
"backend": "local",
|
|
}
|
|
except FileNotFoundError:
|
|
pass # Fall through to remote
|
|
|
|
client, backend = await self._remote_client()
|
|
source = await client.fetch_component(comp_name, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"component": comp_name,
|
|
"source": source,
|
|
"backend": backend,
|
|
}
|
|
|
|
@self.action("get_demo", "Get component demo/example")
|
|
async def get_demo(
|
|
ctx: MCPContext,
|
|
name: str | None = None,
|
|
component: str | None = None,
|
|
framework: str | None = None,
|
|
) -> dict:
|
|
comp_name = name or component
|
|
if not comp_name:
|
|
return {"error": "Component name is required"}
|
|
|
|
fw = framework or _current_framework
|
|
|
|
if self._use_local(fw):
|
|
try:
|
|
demo = await self._local.fetch_component_demo(comp_name, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"component": comp_name,
|
|
"demo": demo,
|
|
"backend": "local",
|
|
}
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
client, backend = await self._remote_client()
|
|
demo = await client.fetch_component_demo(comp_name, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"component": comp_name,
|
|
"demo": demo,
|
|
"backend": backend,
|
|
}
|
|
|
|
@self.action("get_metadata", "Get component metadata")
|
|
async def get_metadata(
|
|
ctx: MCPContext,
|
|
name: str | None = None,
|
|
component: str | None = None,
|
|
framework: str | None = None,
|
|
) -> dict:
|
|
comp_name = name or component
|
|
if not comp_name:
|
|
return {"error": "Component name is required"}
|
|
|
|
fw = framework or _current_framework
|
|
|
|
if self._use_local(fw):
|
|
metadata = await self._local.fetch_component_metadata(comp_name, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"component": comp_name,
|
|
"metadata": metadata,
|
|
}
|
|
|
|
client, _ = await self._remote_client()
|
|
metadata = await client.fetch_component_metadata(comp_name, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"component": comp_name,
|
|
"metadata": metadata,
|
|
}
|
|
|
|
@self.action("list_blocks", "List UI blocks")
|
|
async def list_blocks(
|
|
ctx: MCPContext,
|
|
framework: str | None = None,
|
|
category: str | None = None,
|
|
) -> dict:
|
|
fw = framework or _current_framework
|
|
|
|
if self._use_local(fw):
|
|
blocks = await self._local.list_blocks(fw)
|
|
source = "local"
|
|
else:
|
|
client, source = await self._remote_client()
|
|
blocks = await client.list_blocks(fw)
|
|
|
|
if category:
|
|
blocks = [b for b in blocks if b.get("category") == category]
|
|
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"source": source,
|
|
"total": len(blocks),
|
|
"blocks": blocks,
|
|
}
|
|
|
|
@self.action("get_block", "Get block implementation")
|
|
async def get_block(
|
|
ctx: MCPContext,
|
|
name: str | None = None,
|
|
block: str | None = None,
|
|
framework: str | None = None,
|
|
) -> dict:
|
|
block_name = name or block
|
|
if not block_name:
|
|
return {"error": "Block name is required"}
|
|
|
|
fw = framework or _current_framework
|
|
|
|
if self._use_local(fw):
|
|
try:
|
|
content = await self._local.fetch_block(block_name, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"block": block_name,
|
|
"implementation": content,
|
|
"backend": "local",
|
|
}
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
client, backend = await self._remote_client()
|
|
content = await client.fetch_block(block_name, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"block": block_name,
|
|
"implementation": content,
|
|
"backend": backend,
|
|
}
|
|
|
|
@self.action("search", "Search components")
|
|
async def search(
|
|
ctx: MCPContext,
|
|
query: str | None = None,
|
|
search: str | None = None,
|
|
framework: str | None = None,
|
|
) -> dict:
|
|
q = query or search
|
|
if not q:
|
|
return {"error": "Search query is required"}
|
|
|
|
fw = framework or _current_framework
|
|
|
|
if self._use_local(fw):
|
|
matches = await self._local.search_components(q)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"query": q,
|
|
"source": "local",
|
|
"results": matches,
|
|
}
|
|
|
|
client, source = await self._remote_client()
|
|
matches = await client.search_components(q, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"query": q,
|
|
"source": source,
|
|
"results": matches,
|
|
}
|
|
|
|
@self.action("get_structure", "Browse repository directory structure")
|
|
async def get_structure(
|
|
ctx: MCPContext,
|
|
path: str = "",
|
|
framework: str | None = None,
|
|
depth: int = 3,
|
|
) -> dict:
|
|
fw = framework or _current_framework
|
|
|
|
if self._use_local(fw):
|
|
structure = await self._local.get_directory_structure(path, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"path": path or "pkg/",
|
|
"source": "local",
|
|
"structure": structure,
|
|
}
|
|
|
|
client, source = await self._remote_client()
|
|
structure = await client.get_directory_structure(path, fw)
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"path": path or "/",
|
|
"source": source,
|
|
"structure": structure,
|
|
}
|
|
|
|
@self.action("install", "Install component via CLI")
|
|
async def install(
|
|
ctx: MCPContext,
|
|
name: str | None = None,
|
|
component: str | None = None,
|
|
framework: str | None = None,
|
|
overwrite: bool = False,
|
|
) -> dict:
|
|
comp_name = name or component
|
|
if not comp_name:
|
|
return {"error": "Component name is required"}
|
|
|
|
fw = framework or _current_framework
|
|
|
|
if fw.startswith("hanzo"):
|
|
cmd = f"npx @hanzo/ui add {comp_name}"
|
|
elif fw in ("shadcn", "react"):
|
|
cmd = f"npx shadcn@latest add {comp_name}"
|
|
else:
|
|
return {"error": f"Installation not supported for framework: {fw}"}
|
|
|
|
if overwrite:
|
|
cmd += " --overwrite"
|
|
|
|
proc = await asyncio.create_subprocess_shell(
|
|
cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
|
|
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"component": comp_name,
|
|
"command": cmd,
|
|
"output": stdout.decode() if stdout else "",
|
|
"warnings": stderr.decode() if stderr else "",
|
|
}
|
|
|
|
@self.action("set_framework", "Switch active framework")
|
|
async def set_framework(ctx: MCPContext, framework: str | None = None) -> dict:
|
|
global _current_framework
|
|
if not framework:
|
|
return {"error": "Framework is required"}
|
|
if framework not in FRAMEWORK_CONFIGS:
|
|
return {
|
|
"error": f"Unknown framework: {framework}. Available: {', '.join(FRAMEWORK_CONFIGS)}"
|
|
}
|
|
_current_framework = framework
|
|
return {
|
|
"success": True,
|
|
"framework": self._fw_name(framework),
|
|
"message": f"Switched to {self._fw_name(framework)}",
|
|
}
|
|
|
|
@self.action("get_framework", "Show current and available frameworks")
|
|
async def get_framework(ctx: MCPContext) -> dict:
|
|
registry_up = await self._use_registry()
|
|
return {
|
|
"current": self._fw_name(_current_framework),
|
|
"framework": _current_framework,
|
|
"local_available": self._local.available,
|
|
"registry_available": registry_up,
|
|
"available": [
|
|
{
|
|
"key": key,
|
|
"name": FRAMEWORK_NAMES.get(key, key),
|
|
"has_registry": key.startswith("hanzo"),
|
|
}
|
|
for key in FRAMEWORK_CONFIGS
|
|
],
|
|
}
|
|
|
|
@self.action("create_composition", "Scaffold a composition from components")
|
|
async def create_composition(
|
|
ctx: MCPContext,
|
|
name: str | None = None,
|
|
components: list[str] | None = None,
|
|
description: str | None = None,
|
|
framework: str | None = None,
|
|
) -> dict:
|
|
if not name:
|
|
return {"error": "Composition name is required"}
|
|
|
|
fw = framework or _current_framework
|
|
comps = components or []
|
|
|
|
lines = ["/**", f" * {name}"]
|
|
if description:
|
|
lines.append(f" * {description}")
|
|
lines.append(f" * Framework: {self._fw_name(fw)}")
|
|
lines.append(f" * Components: {', '.join(comps)}")
|
|
lines.append(" */")
|
|
lines.append("")
|
|
|
|
def pascal(s: str) -> str:
|
|
return "".join(p.capitalize() for p in s.split("-"))
|
|
|
|
if fw.startswith("hanzo"):
|
|
for comp in comps:
|
|
lines.append(f'import {{ {pascal(comp)} }} from "@hanzo/ui/{comp}"')
|
|
elif fw in ("shadcn", "react"):
|
|
for comp in comps:
|
|
lines.append(f'import {{ {pascal(comp)} }} from "@/components/ui/{comp}"')
|
|
|
|
lines.append("")
|
|
lines.append(f"export function {name}() {{")
|
|
lines.append(" return (")
|
|
lines.append(' <div className="container mx-auto p-6">')
|
|
for comp in comps:
|
|
lines.append(f" <{pascal(comp)} />")
|
|
lines.append(" </div>")
|
|
lines.append(" )")
|
|
lines.append("}")
|
|
|
|
code = "\n".join(lines) + "\n"
|
|
|
|
return {
|
|
"framework": self._fw_name(fw),
|
|
"name": name,
|
|
"code": code,
|
|
"components": comps,
|
|
}
|
|
|
|
@self.action("list_packages", "List all UI packages (local only)")
|
|
async def list_packages(ctx: MCPContext) -> dict:
|
|
if not self._local.available:
|
|
return {
|
|
"error": "Local hanzo/ui repo not found. Set HANZO_UI_PATH or clone to ~/work/hanzo/ui"
|
|
}
|
|
|
|
packages = await self._local.list_packages()
|
|
return {
|
|
"source": "local",
|
|
"total": len(packages),
|
|
"packages": packages,
|
|
}
|
|
|
|
@self.action("read_file", "Read any file from the UI repo by relative path")
|
|
async def read_file(
|
|
ctx: MCPContext,
|
|
path: str | None = None,
|
|
file: str | None = None,
|
|
) -> dict:
|
|
file_path = path or file
|
|
if not file_path:
|
|
return {"error": "File path is required (relative to hanzo/ui root)"}
|
|
|
|
if not self._local.available:
|
|
return {
|
|
"error": "Local hanzo/ui repo not found. Set HANZO_UI_PATH or clone to ~/work/hanzo/ui"
|
|
}
|
|
|
|
content = await self._local.read_file(file_path)
|
|
return {
|
|
"path": file_path,
|
|
"source": "local",
|
|
"content": content,
|
|
}
|
|
|
|
# --- Search / RAG actions (Hanzo Cloud backend) ---
|
|
|
|
@self.action("ask", "Ask a question about UI components (RAG-powered via Hanzo Cloud)")
|
|
async def ask(
|
|
ctx: MCPContext,
|
|
question: str | None = None,
|
|
query: str | None = None,
|
|
) -> dict:
|
|
q = question or query
|
|
if not q:
|
|
return {"error": "Question is required"}
|
|
|
|
from .vector_index import chat_about_components
|
|
|
|
return await chat_about_components(q)
|
|
|
|
@self.action("semantic_search", "Semantic search over UI components (Hanzo Cloud)")
|
|
async def semantic_search(
|
|
ctx: MCPContext,
|
|
query: str | None = None,
|
|
question: str | None = None,
|
|
limit: int = 10,
|
|
tags: list[str] | None = None,
|
|
) -> dict:
|
|
q = query or question
|
|
if not q:
|
|
return {"error": "Query is required"}
|
|
|
|
from .vector_index import search_components
|
|
|
|
results = await search_components(q, limit=limit, tags=tags)
|
|
return {"query": q, "results": results}
|
|
|
|
@self.action("index_status", "Check search index status")
|
|
async def index_status(ctx: MCPContext) -> dict:
|
|
from .vector_index import get_index_stats
|
|
|
|
return await get_index_stats()
|
|
|
|
@self.action("rebuild_index", "Re-index UI components into Hanzo Cloud search")
|
|
async def rebuild_index(ctx: MCPContext) -> dict:
|
|
from .vector_index import index_from_local, index_from_registry
|
|
|
|
if self._local.available:
|
|
return await index_from_local()
|
|
return await index_from_registry()
|
|
|
|
# Inherits call() and register() from BaseTool — action routing is automatic
|
|
|
|
|
|
# Singleton reference
|
|
ui_tool = UiTool
|