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
+3
View File
@@ -0,0 +1,3 @@
"""
Webber CLI - Command-line interface for the multi-agent system.
"""
+6
View File
@@ -0,0 +1,6 @@
"""
CLI commands.
"""
from src.cli.commands import chat, explore, version
__all__ = ["chat", "explore", "version"]
+128
View File
@@ -0,0 +1,128 @@
"""
Chat command - interactive conversation mode.
"""
import asyncio
from pathlib import Path
import typer
from src.cli.theme import get_theme
from src.cli.ui.console import get_console
from src.cli.session.loop import AgenticLoop
from src.shared.logging import setup_logging
console = get_console()
def chat_command(
directory: str = typer.Option(
".",
"--directory",
"-d",
help="Working directory to explore",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-V",
help="Show detailed output and debug logging",
),
) -> None:
"""
Start interactive chat session.
Enters a conversation loop where you can ask questions about the codebase.
The explore agent will search files, read code, and answer questions.
Examples:
webber chat
webber chat -d ./src
webber chat --verbose
"""
# Set up logging
log_level = "DEBUG" if verbose else "WARNING"
setup_logging(log_level)
# Resolve directory
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)
# Run the async chat loop
try:
asyncio.run(_chat_loop(working_dir, verbose))
except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/]")
async def _chat_loop(working_dir: str, verbose: bool) -> None:
"""Async chat loop implementation."""
from src.domains.agents.explore import explore_agent
# Create the agentic loop
loop = AgenticLoop(
agent=explore_agent,
console=console,
working_dir=working_dir,
)
# Display welcome
loop.display_welcome()
# Main conversation loop
while True:
try:
# Get user input
user_input = console.input("[prompt]>[/] ").strip()
# Handle special commands
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"):
loop.state.clear_history()
console.print("[info]History cleared.[/]")
continue
if user_input.lower() in ("status", "/status"):
loop.display_status()
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():
loop.set_working_dir(str(new_path))
else:
console.print(f"[error]Directory not found:[/] {new_dir}")
continue
# Process with agent
theme = get_theme()
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
response = await loop.run_turn(user_input)
# Display response
console.print()
loop.display_response(response)
console.print()
except KeyboardInterrupt:
console.print("\n[dim]Use 'exit' to quit or press Ctrl+C again.[/]")
try:
# Wait briefly for second Ctrl+C
await asyncio.sleep(0.5)
except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/]")
break
except Exception as e:
console.print(f"[error]Error:[/] {e}")
if verbose:
console.print_exception()
+83
View File
@@ -0,0 +1,83 @@
"""
Explore command - one-shot codebase exploration.
"""
import asyncio
from pathlib import Path
import typer
from rich.panel import Panel
from src.cli.theme import get_theme
from src.cli.ui.console import get_console
from src.cli.ui.display import format_response
from src.shared.logging import setup_logging
console = get_console()
def explore_command(
query: str = typer.Argument(..., help="What to search for in the codebase"),
directory: str = typer.Option(
".",
"--directory",
"-d",
help="Working directory to explore",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-V",
help="Show detailed output",
),
) -> None:
"""
One-shot codebase exploration.
Searches the codebase for the given query and returns findings.
Examples:
webber explore "where is config loaded"
webber explore "find all API endpoints" -d ./src
webber explore "how does authentication work"
"""
# Set up logging based on verbosity
log_level = "DEBUG" if verbose else "WARNING"
setup_logging(log_level)
# Resolve directory
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)
console.print(f"[dim]Exploring:[/] [path]{working_dir}[/]")
console.print(f"[dim]Query:[/] {query}\n")
# Run the exploration
asyncio.run(_explore_async(query, working_dir, verbose))
async def _explore_async(query: str, working_dir: str, verbose: bool) -> None:
"""Async exploration implementation."""
from src.domains.agents.explore import explore
theme = get_theme()
try:
with console.status("[info]Searching codebase...[/]", spinner=theme.spinner):
result = await explore(query, working_dir=working_dir)
# Display result
formatted = format_response(result)
console.print(Panel(
formatted,
title="[success]Findings[/]",
border_style=theme.colors.border_success,
))
except Exception as e:
console.print(f"[error]Error:[/] {e}")
if verbose:
console.print_exception()
raise typer.Exit(1)
+26
View File
@@ -0,0 +1,26 @@
"""
Version command.
"""
from rich.console import Console
from rich.panel import Panel
from src.shared.config import get_settings
console = Console()
def show_version() -> None:
"""Display version information."""
settings = get_settings()
version_info = f"""[bold blue]{settings.app_name}[/] [green]v{settings.app_version}[/]
{settings.app_description}
[dim]Configuration:[/]
Ollama URL: {settings.ollama_url}
Model: {settings.ollama_agent_model}
Debug: {settings.debug}
"""
console.print(Panel(version_info, title="Version Info", border_style="blue"))
+64
View File
@@ -0,0 +1,64 @@
"""
Webber CLI main entry point.
Usage:
webber --help
webber --version
webber chat [OPTIONS]
webber explore QUERY [OPTIONS]
"""
import typer
from rich.console import Console
from src.shared.config import get_settings
# Create Typer app
app = typer.Typer(
name="webber",
help="Multi-Agent AI Development System",
no_args_is_help=True,
add_completion=False,
)
console = Console()
def version_callback(value: bool) -> None:
"""Display version and exit."""
if value:
settings = get_settings()
console.print(f"[bold blue]{settings.app_name}[/] version [green]{settings.app_version}[/]")
console.print(f"[dim]{settings.app_description}[/]")
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 - Multi-Agent AI Development System.
A CLI tool for codebase exploration and development assistance
powered by local LLMs via Ollama.
"""
pass
# Import and register commands
from src.cli.commands import chat, explore, version # noqa: E402, F401
# Register subcommands
app.command(name="chat")(chat.chat_command)
app.command(name="explore")(explore.explore_command)
if __name__ == "__main__":
app()
+7
View File
@@ -0,0 +1,7 @@
"""
Session management for CLI.
"""
from src.cli.session.context import SessionState
from src.cli.session.loop import AgenticLoop
__all__ = ["SessionState", "AgenticLoop"]
+60
View File
@@ -0,0 +1,60 @@
"""
Session state management.
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
@dataclass
class Message:
"""Single message in conversation history."""
role: Literal["user", "assistant", "system"]
content: str
timestamp: datetime = field(default_factory=datetime.now)
def __str__(self) -> str:
return f"[{self.role}] {self.content[:50]}..."
@dataclass
class SessionState:
"""
Persistent state for a CLI session.
Tracks conversation history and context.
"""
working_dir: str
messages: list[Message] = field(default_factory=list)
started_at: datetime = field(default_factory=datetime.now)
# Token tracking (for future context management)
estimated_tokens: int = 0
max_tokens: int = 128000
def add_message(self, role: Literal["user", "assistant", "system"], content: str) -> None:
"""Add a message to history."""
self.messages.append(Message(role=role, content=content))
# Rough token estimate (4 chars per token)
self.estimated_tokens += len(content) // 4
def get_history(self, limit: int | None = None) -> list[Message]:
"""Get recent message history."""
if limit:
return self.messages[-limit:]
return self.messages
def clear_history(self) -> None:
"""Clear message history."""
self.messages.clear()
self.estimated_tokens = 0
@property
def message_count(self) -> int:
"""Number of messages in history."""
return len(self.messages)
@property
def is_near_limit(self) -> bool:
"""Check if approaching token limit."""
return self.estimated_tokens > (self.max_tokens * 0.8)
+104
View File
@@ -0,0 +1,104 @@
"""
Agentic conversation loop for interactive CLI.
"""
from typing import Any
from rich.console import Console
from src.cli.session.context import SessionState
from src.cli.ui.display import format_response
from src.domains.agents.base import BaseAgent
from src.shared.logging import logged, trace_span, get_logger
logger = get_logger(__name__)
class AgenticLoop:
"""
Main conversation loop for interactive CLI sessions.
Manages state, executes agent turns, and handles display.
"""
def __init__(
self,
agent: BaseAgent,
console: Console,
working_dir: str,
):
"""
Initialize the agentic loop.
Args:
agent: The agent to use for responses
console: Rich console for output
working_dir: Working directory for exploration
"""
self.agent = agent
self.console = console
self.state = SessionState(working_dir=working_dir)
@logged()
async def run_turn(self, user_input: str) -> str:
"""
Execute a single conversation turn.
Args:
user_input: User's prompt/question
Returns:
Agent's response
"""
# Record user message
self.state.add_message("user", user_input)
async with trace_span("agentic_turn"):
try:
# Run the agent
response = await self.agent.run(
user_input,
working_dir=self.state.working_dir,
)
# Record assistant response
self.state.add_message("assistant", response)
return response
except Exception as e:
logger.exception(f"Agent error: {e}")
error_msg = f"Error: {e}"
self.state.add_message("assistant", error_msg)
raise
def display_response(self, response: str) -> None:
"""Display agent response with formatting."""
formatted = format_response(response)
self.console.print(formatted)
def display_welcome(self) -> None:
"""Display welcome message."""
from src.shared.config import get_settings
settings = get_settings()
self.console.print()
self.console.print(f"[title]{settings.app_name}[/] [dim]v{settings.app_version}[/]")
self.console.print(f"[dim]Working in:[/] [path]{self.state.working_dir}[/]")
self.console.print(f"[dim]Agent:[/] {self.agent.name} - {self.agent.description}")
self.console.print()
self.console.print("[dim]Type 'exit' or Ctrl+C to quit. Type 'clear' to reset history.[/]")
self.console.print()
def display_status(self) -> None:
"""Display session status."""
self.console.print(f"[dim]Messages: {self.state.message_count} | Tokens: ~{self.state.estimated_tokens}[/]")
@property
def working_dir(self) -> str:
"""Get current working directory."""
return self.state.working_dir
def set_working_dir(self, path: str) -> None:
"""Change working directory."""
self.state.working_dir = path
self.console.print(f"[info]Changed directory to:[/] [path]{path}[/]")
+70
View File
@@ -0,0 +1,70 @@
"""
CLI theme configuration.
Centralized color and style definitions for the Webber CLI.
All color choices should be defined here for easy customization.
"""
from dataclasses import dataclass
@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 style for loading indicators
spinner: str = "dots"
# Code syntax highlighting theme
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 instance
DEFAULT_THEME = ThemeConfig()
def get_theme() -> ThemeConfig:
"""Get the current theme configuration."""
# Future: could load from config file or env vars
return DEFAULT_THEME
+7
View File
@@ -0,0 +1,7 @@
"""
CLI UI components.
"""
from src.cli.ui.console import get_console
from src.cli.ui.display import format_response, format_code
__all__ = ["get_console", "format_response", "format_code"]
+37
View File
@@ -0,0 +1,37 @@
"""
Rich console helpers.
"""
from functools import lru_cache
from rich.console import Console
from rich.theme import Theme
from src.cli.theme import get_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)
def print_info(message: str) -> None:
"""Print an info message."""
get_console().print(f"[info]{message}[/]")
def print_warning(message: str) -> None:
"""Print a warning message."""
get_console().print(f"[warning]Warning:[/] {message}")
def print_error(message: str) -> None:
"""Print an error message."""
get_console().print(f"[error]Error:[/] {message}")
def print_success(message: str) -> None:
"""Print a success message."""
get_console().print(f"[success]{message}[/]")
+84
View File
@@ -0,0 +1,84 @@
"""
Output formatting and display helpers.
"""
import re
from rich.markdown import Markdown
from rich.syntax import Syntax
from rich.text import Text
from src.cli.theme import get_theme
from src.cli.ui.console import get_console
def format_response(text: str) -> Markdown | Text:
"""
Format agent response for display.
Detects markdown and formats appropriately.
"""
# Check if response contains markdown patterns
has_markdown = any([
"```" in text, # Code blocks
text.startswith("#"), # Headers
"**" in text or "__" in text, # Bold
"- " in text or "* " in text, # Lists
])
if has_markdown:
return Markdown(text)
else:
return Text(text)
def format_code(code: str, language: str = "python") -> Syntax:
"""
Format code with syntax highlighting.
Args:
code: Source code to format
language: Programming language for highlighting
"""
theme = get_theme()
return Syntax(
code,
language,
theme=theme.syntax_theme,
line_numbers=True,
word_wrap=True,
)
def format_file_path(path: str, line: int | None = None) -> Text:
"""
Format a file path for display.
Args:
path: File path
line: Optional line number
"""
text = Text()
text.append(path, style="path")
if line:
text.append(f":{line}", style="dim")
return text
def truncate_text(text: str, max_length: int = 500, suffix: str = "...") -> str:
"""
Truncate text to maximum length.
Args:
text: Text to truncate
max_length: Maximum character length
suffix: Suffix to add if truncated
"""
if len(text) <= max_length:
return text
return text[:max_length - len(suffix)] + suffix
def strip_ansi(text: str) -> str:
"""Remove ANSI escape codes from text."""
ansi_pattern = re.compile(r'\x1b\[[0-9;]*m')
return ansi_pattern.sub('', text)