Files

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