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,3 @@
|
||||
"""
|
||||
Webber CLI - Command-line interface for the multi-agent system.
|
||||
"""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
CLI commands.
|
||||
"""
|
||||
from src.cli.commands import chat, explore, version
|
||||
|
||||
__all__ = ["chat", "explore", "version"]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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}[/]")
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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}[/]")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user