From b87e61248e590db7c477f4ff477079699b168c15 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 14 Jan 2026 12:06:13 +0100 Subject: [PATCH] feat: add config file support to CLI - Add ~/.webber/config.toml for persistent settings - Support api.url, api.key, cli.mode, cli.stream, history.file - Environment variables override config file values - Add 'config' command to show settings and init config file - Update all commands to use config defaults Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 10 +++ webber-api/docs/COVERAGE.md | 2 +- webber-cli/webber_cli/config.py | 152 ++++++++++++++++++++++++++++++++ webber-cli/webber_cli/main.py | 128 ++++++++++++++++++++------- 4 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 webber-cli/webber_cli/config.py diff --git a/AGENTS.md b/AGENTS.md index 7bc8d8c..5ad104f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,9 +108,19 @@ cd webber-cli - **Tab completion** for commands and file paths - **Command history** persisted to `~/.webber_history` - **Session persistence** - conversations saved and resumable +- **Config file** - persistent settings via `~/.webber/config.toml` - **Runtime mode switching** via `mode plan|default|auto_accept` - **Directory navigation** via `cd ` +**Configuration:** +```bash +# Show current config +.venv/bin/webber-cli config + +# Initialize config file with defaults +.venv/bin/webber-cli config --init +``` + **Note:** The API server must be running for CLI commands to work. --- diff --git a/webber-api/docs/COVERAGE.md b/webber-api/docs/COVERAGE.md index f2674c6..b1a1d68 100644 --- a/webber-api/docs/COVERAGE.md +++ b/webber-api/docs/COVERAGE.md @@ -136,7 +136,7 @@ Last updated: 2026-01-14 |---------|----------|-------------|------------| | **Notebook editing** | Tools | Jupyter cell manipulation | Medium | | **MCP support** | Infrastructure | Model Context Protocol | High | -| **Config file** | CLI | `~/.webber/config.toml` | Low | +| ~~**Config file**~~ | CLI | ✅ `~/.webber/config.toml` with `config` command | Low | | **IDE integration** | CLI | VS Code extension | High | | **Parallel agents** | Orchestration | Concurrent agent execution | High | | **Agent memory** | Orchestration | Shared context between agents | Medium | diff --git a/webber-cli/webber_cli/config.py b/webber-cli/webber_cli/config.py new file mode 100644 index 0000000..4f237f1 --- /dev/null +++ b/webber-cli/webber_cli/config.py @@ -0,0 +1,152 @@ +""" +Configuration management for Webber CLI. + +Loads settings from ~/.webber/config.toml with environment variable overrides. +""" +import os +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +# Config directory and file paths +CONFIG_DIR = Path.home() / ".webber" +CONFIG_FILE = CONFIG_DIR / "config.toml" + +# Default configuration template +DEFAULT_CONFIG = """\ +# Webber CLI Configuration +# https://github.com/jpmschweitzer/webber + +[api] +# API server URL (dev: 8095, prod: 8086) +url = "http://localhost:8095" + +# API key for authentication (optional for dev mode) +# key = "your-api-key" + +[cli] +# Default permission mode: default, plan, auto_accept +mode = "default" + +# Enable streaming by default +stream = true + +[history] +# Command history file location +file = "~/.webber_history" +""" + + +@dataclass +class ApiConfig: + """API connection settings.""" + url: str = "http://localhost:8095" + key: str | None = None + + +@dataclass +class CliConfig: + """CLI behavior settings.""" + mode: str = "default" + stream: bool = True + + +@dataclass +class HistoryConfig: + """History settings.""" + file: str = "~/.webber_history" + + +@dataclass +class Config: + """Complete configuration.""" + api: ApiConfig = field(default_factory=ApiConfig) + cli: CliConfig = field(default_factory=CliConfig) + history: HistoryConfig = field(default_factory=HistoryConfig) + + +def load_config() -> Config: + """ + Load configuration from file with environment variable overrides. + + Priority (highest to lowest): + 1. Environment variables (WEBBER_API_URL, WEBBER_API_KEY, WEBBER_MODE) + 2. Config file (~/.webber/config.toml) + 3. Built-in defaults + + Returns: + Config object with merged settings + """ + config = Config() + + # Load from file if exists + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, "rb") as f: + data = tomllib.load(f) + config = _parse_config(data) + except Exception: + # If config file is invalid, use defaults + pass + + # Apply environment variable overrides + if url := os.environ.get("WEBBER_API_URL"): + config.api.url = url + if key := os.environ.get("WEBBER_API_KEY"): + config.api.key = key + if mode := os.environ.get("WEBBER_MODE"): + config.cli.mode = mode + + return config + + +def _parse_config(data: dict[str, Any]) -> Config: + """Parse config dict into Config object.""" + config = Config() + + if api := data.get("api"): + config.api.url = api.get("url", config.api.url) + config.api.key = api.get("key", config.api.key) + + if cli := data.get("cli"): + config.cli.mode = cli.get("mode", config.cli.mode) + config.cli.stream = cli.get("stream", config.cli.stream) + + if history := data.get("history"): + config.history.file = history.get("file", config.history.file) + + return config + + +def init_config() -> Path: + """ + Initialize config directory and file with defaults. + + Returns: + Path to the created config file + """ + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + if not CONFIG_FILE.exists(): + CONFIG_FILE.write_text(DEFAULT_CONFIG) + + return CONFIG_FILE + + +def get_config_path() -> Path | None: + """Get path to config file if it exists.""" + return CONFIG_FILE if CONFIG_FILE.exists() else None + + +# Module-level cached config +_config: Config | None = None + + +def get_config() -> Config: + """Get cached config, loading if necessary.""" + global _config + if _config is None: + _config = load_config() + return _config diff --git a/webber-cli/webber_cli/main.py b/webber-cli/webber_cli/main.py index d4def7e..4f48e8a 100644 --- a/webber-cli/webber_cli/main.py +++ b/webber-cli/webber_cli/main.py @@ -8,7 +8,6 @@ Usage: webber-cli status """ import asyncio -import os import sys from pathlib import Path @@ -23,13 +22,16 @@ from rich.panel import Panel from rich.prompt import Confirm from webber_cli.client import WebberClient, PermissionMode +from webber_cli.config import get_config, init_config, get_config_path, CONFIG_FILE from webber_cli.theme import get_console, get_theme # === Prompt Toolkit Setup === -# History file location -HISTORY_FILE = Path.home() / ".webber_history" +def _get_history_file() -> Path: + """Get history file path from config.""" + config = get_config() + return Path(config.history.file).expanduser() # Built-in commands for completion BUILTIN_COMMANDS = [ @@ -87,7 +89,7 @@ PROMPT_STYLE = Style.from_dict({ def create_prompt_session(working_dir: str) -> PromptSession: """Create a configured prompt session with history and completion.""" return PromptSession( - history=FileHistory(str(HISTORY_FILE)), + history=FileHistory(str(_get_history_file())), auto_suggest=AutoSuggestFromHistory(), completer=WebberCompleter(working_dir), style=PROMPT_STYLE, @@ -103,12 +105,15 @@ app = typer.Typer( 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") -# Default API key for conversation API (dev mode accepts any non-empty key) -DEFAULT_API_KEY = os.environ.get("WEBBER_API_KEY", "webber-cli-dev-key") +def _get_api_url() -> str: + """Get API URL from config (with env override already applied).""" + return get_config().api.url + + +def _get_api_key() -> str: + """Get API key from config (with env override already applied).""" + return get_config().api.key or "webber-cli-dev-key" def version_callback(value: bool) -> None: @@ -154,10 +159,10 @@ def main( @app.command() def sessions( api_url: str = typer.Option( - DEFAULT_API_URL, + None, "--api", "-a", - help="Webber API URL", + help="Webber API URL (default from config)", ), limit: int = typer.Option( 20, @@ -171,12 +176,13 @@ def sessions( Shows recent sessions that can be resumed with 'chat --resume '. """ - asyncio.run(_list_sessions(api_url, limit)) + url = api_url or _get_api_url() + asyncio.run(_list_sessions(url, limit)) async def _list_sessions(api_url: str, limit: int) -> None: """List conversation sessions.""" - async with WebberClient(api_url, api_key=DEFAULT_API_KEY) as client: + async with WebberClient(api_url, api_key=_get_api_key()) as client: # Check API health if not await client.health_check(): console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}") @@ -223,16 +229,16 @@ def chat( help="Working directory for the agent", ), api_url: str = typer.Option( - DEFAULT_API_URL, + None, "--api", "-a", - help="Webber API URL", + help="Webber API URL (default from config)", ), mode: str = typer.Option( - "default", + None, "--mode", "-m", - help="Permission mode: default, plan (read-only), auto_accept (no prompts)", + help="Permission mode: default, plan, auto_accept (default from config)", ), resume: str = typer.Option( None, @@ -241,10 +247,10 @@ def chat( help="Resume a previous session by ID (use 'sessions' to list)", ), stream: bool = typer.Option( - True, + None, "--stream/--no-stream", "-s", - help="Stream responses in real-time", + help="Stream responses in real-time (default from config)", ), ) -> None: """ @@ -263,6 +269,12 @@ def chat( Use --resume to continue a previous session. """ + # Resolve config defaults + config = get_config() + url = api_url or config.api.url + mode_str = mode or config.cli.mode + use_stream = stream if stream is not None else config.cli.stream + working_dir = str(Path(directory).resolve()) if not Path(working_dir).exists(): @@ -271,9 +283,9 @@ def chat( # Parse and validate mode try: - permission_mode = PermissionMode(mode) + permission_mode = PermissionMode(mode_str) except ValueError: - console.print(f"[error]Error:[/] Invalid mode: {mode}") + console.print(f"[error]Error:[/] Invalid mode: {mode_str}") console.print("[dim]Valid modes: default, plan, auto_accept[/]") raise typer.Exit(1) @@ -284,7 +296,7 @@ def chat( permission_mode = PermissionMode.default try: - asyncio.run(_chat_loop(api_url, working_dir, permission_mode, stream, resume)) + asyncio.run(_chat_loop(url, working_dir, permission_mode, use_stream, resume)) except KeyboardInterrupt: console.print("\n[dim]Goodbye![/]") @@ -302,7 +314,7 @@ async def _chat_loop( conversation_id: str | None = None conversation_title: str | None = None - async with WebberClient(api_url, api_key=DEFAULT_API_KEY) as client: + async with WebberClient(api_url, api_key=_get_api_key()) as client: # Check API health if not await client.health_check(): console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}") @@ -510,14 +522,15 @@ async def _chat_loop( @app.command() def status( api_url: str = typer.Option( - DEFAULT_API_URL, + None, "--api", "-a", - help="Webber API URL", + help="Webber API URL (default from config)", ), ) -> None: """Check API status and list available agents.""" - asyncio.run(_status(api_url)) + url = api_url or _get_api_url() + asyncio.run(_status(url)) async def _status(api_url: str) -> None: @@ -536,6 +549,54 @@ async def _status(api_url: str) -> None: console.print("[error]Status:[/] Cannot connect") +@app.command() +def config( + init: bool = typer.Option( + False, + "--init", + "-i", + help="Initialize config file with defaults", + ), +) -> None: + """ + Show or initialize configuration. + + Without --init, displays current config and source. + With --init, creates ~/.webber/config.toml with defaults. + """ + if init: + path = init_config() + console.print(f"[success]Config initialized:[/] {path}") + console.print("[dim]Edit this file to customize settings.[/]") + return + + # Show current config + cfg = get_config() + config_path = get_config_path() + + console.print("[title]Webber Configuration[/]\n") + + if config_path: + console.print(f"[dim]Config file:[/] {config_path}") + else: + console.print(f"[dim]Config file:[/] [warning]Not found[/] (using defaults)") + console.print(f"[dim]Run 'webber-cli config --init' to create {CONFIG_FILE}[/]") + + console.print() + console.print("[info]API Settings[/]") + console.print(f" url: {cfg.api.url}") + console.print(f" key: {'***' if cfg.api.key else '[dim]not set[/]'}") + + console.print() + console.print("[info]CLI Settings[/]") + console.print(f" mode: {cfg.cli.mode}") + console.print(f" stream: {cfg.cli.stream}") + + console.print() + console.print("[info]History[/]") + console.print(f" file: {cfg.history.file}") + + # Keep 'explore' as an alias for 'chat --mode plan' for backwards compatibility @app.command(hidden=True) def explore( @@ -547,16 +608,16 @@ def explore( help="Working directory", ), api_url: str = typer.Option( - DEFAULT_API_URL, + None, "--api", "-a", - help="Webber API URL", + help="Webber API URL (default from config)", ), stream: bool = typer.Option( - True, + None, "--stream/--no-stream", "-s", - help="Stream responses in real-time", + help="Stream responses in real-time (default from config)", ), ) -> None: """ @@ -564,6 +625,11 @@ def explore( Runs the Task agent in plan (read-only) mode for a single query. """ + # Resolve config defaults + config = get_config() + url = api_url or config.api.url + use_stream = stream if stream is not None else config.cli.stream + console.print("[dim]Note: 'explore' is deprecated. Use 'chat --mode plan' for interactive mode.[/]") console.print() @@ -573,7 +639,7 @@ def explore( console.print(f"[error]Error:[/] Directory not found: {working_dir}") raise typer.Exit(1) - asyncio.run(_explore(api_url, query, working_dir, stream)) + asyncio.run(_explore(url, query, working_dir, use_stream)) async def _explore(