refactor: reorganize into monorepo with separate subprojects
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 2m27s

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:
2026-01-10 10:37:47 +01:00
co-authored by Claude Opus 4.5
parent f4e8552298
commit 3b58fa4f8b
121 changed files with 2034 additions and 284 deletions
+42
View File
@@ -0,0 +1,42 @@
# Webber CLI
Command-line client for the Webber API.
## Installation
```bash
# Create virtual environment
python3.12 -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Install package in editable mode
pip install -e .
```
## Usage
```bash
# Check API connection
webber-cli status
# Explore a codebase
webber-cli explore "find all python files" -d /path/to/project
# Interactive chat mode
webber-cli chat -d /path/to/project
```
## Configuration
Set the API URL via environment variable:
```bash
export WEBBER_API_URL=http://localhost:8095
```
## Requirements
- Python 3.12+
- Webber API running (see `../webber-api/`)
+40
View File
@@ -0,0 +1,40 @@
[project]
name = "webber-cli"
version = "0.1.0"
description = "Webber CLI - Command-line client for Webber API"
authors = [
{name = "jpmschweitzer"}
]
readme = "README.md"
requires-python = ">=3.12"
license = {text = "MIT"}
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Code Generators",
]
[project.scripts]
webber-cli = "webber_cli.main:app"
[build-system]
requires = ["setuptools>=75.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["webber_cli*"]
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["webber_cli"]
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM"]
ignore = ["E501"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
+4
View File
@@ -0,0 +1,4 @@
# Webber CLI Dependencies
httpx~=0.28.1
typer~=0.15.0
rich~=13.9.0
+7
View File
@@ -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"
+141
View File
@@ -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()
+261
View File
@@ -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()
+75
View File
@@ -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)