refactor: reorganize into monorepo with separate subprojects
Structure webber into three independent subprojects: - webber-api/: FastAPI backend server with all agent code - webber-cli/: Standalone CLI client (renamed from cli/ to webber_cli/) - webber-sandbox/: Test project for functional testing Key changes: - Each subproject has its own .venv (Python 3.12+) - Added sandbox.sh for managing test project templates - Created sandbox-templates/ with calculator-cli and empty starter - Updated CI/CD for prefixed tags (api/v*, cli/v*) - Added comprehensive AGENTS.md with operational instructions - Added gitignore filtering to glob and grep tools - Created pyproject.toml for each subproject Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Webber CLI Client.
|
||||
|
||||
A standalone CLI that communicates with the Webber API backend.
|
||||
Can be extracted as a separate package.
|
||||
"""
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Webber API client.
|
||||
|
||||
Communicates with the Webber API backend for agent execution.
|
||||
"""
|
||||
import httpx
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResponse:
|
||||
"""Response from agent execution."""
|
||||
response: str
|
||||
agent_type: str
|
||||
success: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentInfo:
|
||||
"""Information about an available agent."""
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class WebberClient:
|
||||
"""
|
||||
Client for the Webber API.
|
||||
|
||||
Usage:
|
||||
client = WebberClient("http://localhost:8086")
|
||||
response = await client.run_agent("explore", "find python files", "/path/to/project")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8086",
|
||||
api_key: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
):
|
||||
"""
|
||||
Initialize the Webber client.
|
||||
|
||||
Args:
|
||||
base_url: Webber API URL
|
||||
api_key: Optional API key for authentication
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create the HTTP client."""
|
||||
if self._client is None or self._client.is_closed:
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if the API is healthy."""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get("/health")
|
||||
return response.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
async def list_agents(self) -> list[AgentInfo]:
|
||||
"""List available agents."""
|
||||
client = await self._get_client()
|
||||
response = await client.get("/agents/")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return [AgentInfo(**a) for a in data.get("agents", [])]
|
||||
|
||||
async def get_agent(self, agent_type: str) -> AgentInfo | None:
|
||||
"""Get information about a specific agent."""
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"/agents/{agent_type}")
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return AgentInfo(**response.json())
|
||||
|
||||
async def run_agent(
|
||||
self,
|
||||
agent_type: str,
|
||||
prompt: str,
|
||||
working_dir: str = ".",
|
||||
) -> AgentResponse:
|
||||
"""
|
||||
Run an agent with the given prompt.
|
||||
|
||||
Args:
|
||||
agent_type: Type of agent (e.g., "explore")
|
||||
prompt: User prompt/query
|
||||
working_dir: Working directory for the agent
|
||||
|
||||
Returns:
|
||||
AgentResponse with the result
|
||||
"""
|
||||
client = await self._get_client()
|
||||
response = await client.post(
|
||||
"/agents/run",
|
||||
json={
|
||||
"agent_type": agent_type,
|
||||
"prompt": prompt,
|
||||
"working_dir": working_dir,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return AgentResponse(
|
||||
response=data.get("response", ""),
|
||||
agent_type=data.get("agent_type", agent_type),
|
||||
success=data.get("success", True),
|
||||
error=data.get("error"),
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> "WebberClient":
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
"""Async context manager exit."""
|
||||
await self.close()
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Webber CLI - Client for the Webber API.
|
||||
|
||||
Usage:
|
||||
webber-cli --help
|
||||
webber-cli chat [OPTIONS]
|
||||
webber-cli explore QUERY [OPTIONS]
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
|
||||
from webber_cli.client import WebberClient
|
||||
from webber_cli.theme import get_console, get_theme
|
||||
|
||||
app = typer.Typer(
|
||||
name="webber-cli",
|
||||
help="CLI client for the Webber API",
|
||||
no_args_is_help=True,
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
console = get_console()
|
||||
|
||||
# Default API URL (can be overridden via env or option)
|
||||
# Development port is 8095, production is 8086
|
||||
DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095")
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
"""Display version and exit."""
|
||||
if value:
|
||||
from cli import __version__
|
||||
console.print(f"[title]webber-cli[/] version [success]{__version__}[/]")
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main(
|
||||
version: bool = typer.Option(
|
||||
False,
|
||||
"--version",
|
||||
"-v",
|
||||
callback=version_callback,
|
||||
is_eager=True,
|
||||
help="Show version and exit",
|
||||
),
|
||||
) -> None:
|
||||
"""Webber CLI - Talk to the Webber API."""
|
||||
pass
|
||||
|
||||
|
||||
@app.command()
|
||||
def chat(
|
||||
directory: str = typer.Option(
|
||||
".",
|
||||
"--directory",
|
||||
"-d",
|
||||
help="Working directory for exploration",
|
||||
),
|
||||
api_url: str = typer.Option(
|
||||
DEFAULT_API_URL,
|
||||
"--api",
|
||||
"-a",
|
||||
help="Webber API URL",
|
||||
),
|
||||
agent: str = typer.Option(
|
||||
"explore",
|
||||
"--agent",
|
||||
help="Agent to use",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Start interactive chat session.
|
||||
|
||||
Connects to the Webber API backend for agent execution.
|
||||
"""
|
||||
working_dir = str(Path(directory).resolve())
|
||||
|
||||
if not Path(working_dir).exists():
|
||||
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
asyncio.run(_chat_loop(api_url, agent, working_dir))
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Goodbye![/]")
|
||||
|
||||
|
||||
async def _chat_loop(api_url: str, agent_type: str, working_dir: str) -> None:
|
||||
"""Interactive chat loop."""
|
||||
theme = get_theme()
|
||||
|
||||
async with WebberClient(api_url) as client:
|
||||
# Check API health
|
||||
if not await client.health_check():
|
||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
||||
console.print("[dim]Make sure the server is running: ./wakeup.sh[/]")
|
||||
return
|
||||
|
||||
# Get agent info
|
||||
agent_info = await client.get_agent(agent_type)
|
||||
if not agent_info:
|
||||
console.print(f"[error]Error:[/] Unknown agent: {agent_type}")
|
||||
agents = await client.list_agents()
|
||||
console.print("[dim]Available agents:[/]")
|
||||
for a in agents:
|
||||
console.print(f" - {a.name}: {a.description}")
|
||||
return
|
||||
|
||||
# Welcome message
|
||||
console.print()
|
||||
console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]")
|
||||
console.print(f"[dim]Working in:[/] [path]{working_dir}[/]")
|
||||
console.print(f"[dim]Agent:[/] {agent_info.name} - {agent_info.description}")
|
||||
console.print()
|
||||
console.print("[dim]Type 'exit' to quit, 'clear' to clear screen.[/]")
|
||||
console.print()
|
||||
|
||||
# Chat loop
|
||||
while True:
|
||||
try:
|
||||
user_input = console.input("[prompt]>[/] ").strip()
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
if user_input.lower() in ("exit", "quit", "/exit", "/quit"):
|
||||
console.print("[dim]Goodbye![/]")
|
||||
break
|
||||
|
||||
if user_input.lower() in ("clear", "/clear"):
|
||||
console.clear()
|
||||
continue
|
||||
|
||||
if user_input.lower().startswith("cd "):
|
||||
new_dir = user_input[3:].strip()
|
||||
new_path = Path(new_dir).resolve()
|
||||
if new_path.exists() and new_path.is_dir():
|
||||
working_dir = str(new_path)
|
||||
console.print(f"[info]Changed to:[/] [path]{working_dir}[/]")
|
||||
else:
|
||||
console.print(f"[error]Directory not found:[/] {new_dir}")
|
||||
continue
|
||||
|
||||
# Call the API
|
||||
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
|
||||
result = await client.run_agent(agent_type, user_input, working_dir)
|
||||
|
||||
console.print()
|
||||
if result.success:
|
||||
console.print(Markdown(result.response))
|
||||
else:
|
||||
console.print(f"[error]Error:[/] {result.error}")
|
||||
console.print()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Use 'exit' to quit.[/]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[error]Error:[/] {e}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def explore(
|
||||
query: str = typer.Argument(..., help="What to search for"),
|
||||
directory: str = typer.Option(
|
||||
".",
|
||||
"--directory",
|
||||
"-d",
|
||||
help="Working directory",
|
||||
),
|
||||
api_url: str = typer.Option(
|
||||
DEFAULT_API_URL,
|
||||
"--api",
|
||||
"-a",
|
||||
help="Webber API URL",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
One-shot codebase exploration.
|
||||
|
||||
Sends a query to the Webber API and displays the result.
|
||||
"""
|
||||
working_dir = str(Path(directory).resolve())
|
||||
|
||||
if not Path(working_dir).exists():
|
||||
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_explore(api_url, query, working_dir))
|
||||
|
||||
|
||||
async def _explore(api_url: str, query: str, working_dir: str) -> None:
|
||||
"""Execute exploration query."""
|
||||
theme = get_theme()
|
||||
|
||||
async with WebberClient(api_url) as client:
|
||||
# Check API health
|
||||
if not await client.health_check():
|
||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
||||
console.print("[dim]Make sure the server is running: ./wakeup.sh[/]")
|
||||
return
|
||||
|
||||
console.print(f"[dim]Exploring:[/] [path]{working_dir}[/]")
|
||||
console.print(f"[dim]Query:[/] {query}")
|
||||
console.print()
|
||||
|
||||
with console.status("[info]Searching...[/]", spinner=theme.spinner):
|
||||
result = await client.run_agent("explore", query, working_dir)
|
||||
|
||||
if result.success:
|
||||
console.print(Panel(
|
||||
Markdown(result.response),
|
||||
title="[success]Findings[/]",
|
||||
border_style=theme.colors.border_success,
|
||||
))
|
||||
else:
|
||||
console.print(Panel(
|
||||
f"[error]{result.error}[/]",
|
||||
title="[error]Error[/]",
|
||||
border_style=theme.colors.border_error,
|
||||
))
|
||||
|
||||
|
||||
@app.command()
|
||||
def status(
|
||||
api_url: str = typer.Option(
|
||||
DEFAULT_API_URL,
|
||||
"--api",
|
||||
"-a",
|
||||
help="Webber API URL",
|
||||
),
|
||||
) -> None:
|
||||
"""Check API status and list available agents."""
|
||||
asyncio.run(_status(api_url))
|
||||
|
||||
|
||||
async def _status(api_url: str) -> None:
|
||||
"""Check API status."""
|
||||
async with WebberClient(api_url) as client:
|
||||
console.print(f"[dim]API URL:[/] {api_url}")
|
||||
|
||||
if await client.health_check():
|
||||
console.print("[success]Status:[/] Connected")
|
||||
|
||||
agents = await client.list_agents()
|
||||
console.print(f"\n[dim]Available agents ({len(agents)}):[/]")
|
||||
for agent in agents:
|
||||
console.print(f" [info]{agent.name}[/]: {agent.description}")
|
||||
else:
|
||||
console.print("[error]Status:[/] Cannot connect")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
CLI theme configuration.
|
||||
|
||||
Centralized color and style definitions.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
from rich.console import Console
|
||||
from rich.theme import Theme
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeColors:
|
||||
"""Color palette for the CLI."""
|
||||
|
||||
# Semantic colors
|
||||
info: str = "steel_blue"
|
||||
warning: str = "dark_orange"
|
||||
error: str = "red3"
|
||||
success: str = "sea_green3"
|
||||
|
||||
# UI elements
|
||||
prompt: str = "steel_blue bold"
|
||||
title: str = "steel_blue bold"
|
||||
path: str = "steel_blue underline"
|
||||
code: str = "sea_green3"
|
||||
highlight: str = "medium_purple1"
|
||||
dim: str = "dim white"
|
||||
|
||||
# Panel borders
|
||||
border_default: str = "steel_blue"
|
||||
border_success: str = "sea_green3"
|
||||
border_error: str = "red3"
|
||||
border_warning: str = "dark_orange"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeConfig:
|
||||
"""Complete theme configuration."""
|
||||
|
||||
colors: ThemeColors = ThemeColors()
|
||||
spinner: str = "dots"
|
||||
syntax_theme: str = "monokai"
|
||||
|
||||
def to_rich_theme_dict(self) -> dict[str, str]:
|
||||
"""Convert to Rich theme dictionary."""
|
||||
return {
|
||||
"info": self.colors.info,
|
||||
"warning": self.colors.warning,
|
||||
"error": self.colors.error,
|
||||
"success": self.colors.success,
|
||||
"prompt": self.colors.prompt,
|
||||
"title": self.colors.title,
|
||||
"path": self.colors.path,
|
||||
"code": self.colors.code,
|
||||
"highlight": self.colors.highlight,
|
||||
"dim": self.colors.dim,
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_THEME = ThemeConfig()
|
||||
|
||||
|
||||
def get_theme() -> ThemeConfig:
|
||||
"""Get the current theme configuration."""
|
||||
return DEFAULT_THEME
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_console() -> Console:
|
||||
"""Get the shared console instance with theme applied."""
|
||||
theme = get_theme()
|
||||
rich_theme = Theme(theme.to_rich_theme_dict())
|
||||
return Console(theme=rich_theme)
|
||||
Reference in New Issue
Block a user