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 <noreply@anthropic.com>
This commit is contained in:
@@ -108,9 +108,19 @@ cd webber-cli
|
|||||||
- **Tab completion** for commands and file paths
|
- **Tab completion** for commands and file paths
|
||||||
- **Command history** persisted to `~/.webber_history`
|
- **Command history** persisted to `~/.webber_history`
|
||||||
- **Session persistence** - conversations saved and resumable
|
- **Session persistence** - conversations saved and resumable
|
||||||
|
- **Config file** - persistent settings via `~/.webber/config.toml`
|
||||||
- **Runtime mode switching** via `mode plan|default|auto_accept`
|
- **Runtime mode switching** via `mode plan|default|auto_accept`
|
||||||
- **Directory navigation** via `cd <path>`
|
- **Directory navigation** via `cd <path>`
|
||||||
|
|
||||||
|
**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.
|
**Note:** The API server must be running for CLI commands to work.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ Last updated: 2026-01-14
|
|||||||
|---------|----------|-------------|------------|
|
|---------|----------|-------------|------------|
|
||||||
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
|
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
|
||||||
| **MCP support** | Infrastructure | Model Context Protocol | High |
|
| **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 |
|
| **IDE integration** | CLI | VS Code extension | High |
|
||||||
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
|
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
|
||||||
| **Agent memory** | Orchestration | Shared context between agents | Medium |
|
| **Agent memory** | Orchestration | Shared context between agents | Medium |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,7 +8,6 @@ Usage:
|
|||||||
webber-cli status
|
webber-cli status
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -23,13 +22,16 @@ from rich.panel import Panel
|
|||||||
from rich.prompt import Confirm
|
from rich.prompt import Confirm
|
||||||
|
|
||||||
from webber_cli.client import WebberClient, PermissionMode
|
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
|
from webber_cli.theme import get_console, get_theme
|
||||||
|
|
||||||
|
|
||||||
# === Prompt Toolkit Setup ===
|
# === Prompt Toolkit Setup ===
|
||||||
|
|
||||||
# History file location
|
def _get_history_file() -> Path:
|
||||||
HISTORY_FILE = Path.home() / ".webber_history"
|
"""Get history file path from config."""
|
||||||
|
config = get_config()
|
||||||
|
return Path(config.history.file).expanduser()
|
||||||
|
|
||||||
# Built-in commands for completion
|
# Built-in commands for completion
|
||||||
BUILTIN_COMMANDS = [
|
BUILTIN_COMMANDS = [
|
||||||
@@ -87,7 +89,7 @@ PROMPT_STYLE = Style.from_dict({
|
|||||||
def create_prompt_session(working_dir: str) -> PromptSession:
|
def create_prompt_session(working_dir: str) -> PromptSession:
|
||||||
"""Create a configured prompt session with history and completion."""
|
"""Create a configured prompt session with history and completion."""
|
||||||
return PromptSession(
|
return PromptSession(
|
||||||
history=FileHistory(str(HISTORY_FILE)),
|
history=FileHistory(str(_get_history_file())),
|
||||||
auto_suggest=AutoSuggestFromHistory(),
|
auto_suggest=AutoSuggestFromHistory(),
|
||||||
completer=WebberCompleter(working_dir),
|
completer=WebberCompleter(working_dir),
|
||||||
style=PROMPT_STYLE,
|
style=PROMPT_STYLE,
|
||||||
@@ -103,12 +105,15 @@ app = typer.Typer(
|
|||||||
|
|
||||||
console = get_console()
|
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)
|
def _get_api_url() -> str:
|
||||||
DEFAULT_API_KEY = os.environ.get("WEBBER_API_KEY", "webber-cli-dev-key")
|
"""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:
|
def version_callback(value: bool) -> None:
|
||||||
@@ -154,10 +159,10 @@ def main(
|
|||||||
@app.command()
|
@app.command()
|
||||||
def sessions(
|
def sessions(
|
||||||
api_url: str = typer.Option(
|
api_url: str = typer.Option(
|
||||||
DEFAULT_API_URL,
|
None,
|
||||||
"--api",
|
"--api",
|
||||||
"-a",
|
"-a",
|
||||||
help="Webber API URL",
|
help="Webber API URL (default from config)",
|
||||||
),
|
),
|
||||||
limit: int = typer.Option(
|
limit: int = typer.Option(
|
||||||
20,
|
20,
|
||||||
@@ -171,12 +176,13 @@ def sessions(
|
|||||||
|
|
||||||
Shows recent sessions that can be resumed with 'chat --resume <id>'.
|
Shows recent sessions that can be resumed with 'chat --resume <id>'.
|
||||||
"""
|
"""
|
||||||
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:
|
async def _list_sessions(api_url: str, limit: int) -> None:
|
||||||
"""List conversation sessions."""
|
"""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
|
# Check API health
|
||||||
if not await client.health_check():
|
if not await client.health_check():
|
||||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
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",
|
help="Working directory for the agent",
|
||||||
),
|
),
|
||||||
api_url: str = typer.Option(
|
api_url: str = typer.Option(
|
||||||
DEFAULT_API_URL,
|
None,
|
||||||
"--api",
|
"--api",
|
||||||
"-a",
|
"-a",
|
||||||
help="Webber API URL",
|
help="Webber API URL (default from config)",
|
||||||
),
|
),
|
||||||
mode: str = typer.Option(
|
mode: str = typer.Option(
|
||||||
"default",
|
None,
|
||||||
"--mode",
|
"--mode",
|
||||||
"-m",
|
"-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(
|
resume: str = typer.Option(
|
||||||
None,
|
None,
|
||||||
@@ -241,10 +247,10 @@ def chat(
|
|||||||
help="Resume a previous session by ID (use 'sessions' to list)",
|
help="Resume a previous session by ID (use 'sessions' to list)",
|
||||||
),
|
),
|
||||||
stream: bool = typer.Option(
|
stream: bool = typer.Option(
|
||||||
True,
|
None,
|
||||||
"--stream/--no-stream",
|
"--stream/--no-stream",
|
||||||
"-s",
|
"-s",
|
||||||
help="Stream responses in real-time",
|
help="Stream responses in real-time (default from config)",
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -263,6 +269,12 @@ def chat(
|
|||||||
|
|
||||||
Use --resume to continue a previous session.
|
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())
|
working_dir = str(Path(directory).resolve())
|
||||||
|
|
||||||
if not Path(working_dir).exists():
|
if not Path(working_dir).exists():
|
||||||
@@ -271,9 +283,9 @@ def chat(
|
|||||||
|
|
||||||
# Parse and validate mode
|
# Parse and validate mode
|
||||||
try:
|
try:
|
||||||
permission_mode = PermissionMode(mode)
|
permission_mode = PermissionMode(mode_str)
|
||||||
except ValueError:
|
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[/]")
|
console.print("[dim]Valid modes: default, plan, auto_accept[/]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
@@ -284,7 +296,7 @@ def chat(
|
|||||||
permission_mode = PermissionMode.default
|
permission_mode = PermissionMode.default
|
||||||
|
|
||||||
try:
|
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:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[dim]Goodbye![/]")
|
console.print("\n[dim]Goodbye![/]")
|
||||||
|
|
||||||
@@ -302,7 +314,7 @@ async def _chat_loop(
|
|||||||
conversation_id: str | None = None
|
conversation_id: str | None = None
|
||||||
conversation_title: 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
|
# Check API health
|
||||||
if not await client.health_check():
|
if not await client.health_check():
|
||||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
||||||
@@ -510,14 +522,15 @@ async def _chat_loop(
|
|||||||
@app.command()
|
@app.command()
|
||||||
def status(
|
def status(
|
||||||
api_url: str = typer.Option(
|
api_url: str = typer.Option(
|
||||||
DEFAULT_API_URL,
|
None,
|
||||||
"--api",
|
"--api",
|
||||||
"-a",
|
"-a",
|
||||||
help="Webber API URL",
|
help="Webber API URL (default from config)",
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check API status and list available agents."""
|
"""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:
|
async def _status(api_url: str) -> None:
|
||||||
@@ -536,6 +549,54 @@ async def _status(api_url: str) -> None:
|
|||||||
console.print("[error]Status:[/] Cannot connect")
|
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
|
# Keep 'explore' as an alias for 'chat --mode plan' for backwards compatibility
|
||||||
@app.command(hidden=True)
|
@app.command(hidden=True)
|
||||||
def explore(
|
def explore(
|
||||||
@@ -547,16 +608,16 @@ def explore(
|
|||||||
help="Working directory",
|
help="Working directory",
|
||||||
),
|
),
|
||||||
api_url: str = typer.Option(
|
api_url: str = typer.Option(
|
||||||
DEFAULT_API_URL,
|
None,
|
||||||
"--api",
|
"--api",
|
||||||
"-a",
|
"-a",
|
||||||
help="Webber API URL",
|
help="Webber API URL (default from config)",
|
||||||
),
|
),
|
||||||
stream: bool = typer.Option(
|
stream: bool = typer.Option(
|
||||||
True,
|
None,
|
||||||
"--stream/--no-stream",
|
"--stream/--no-stream",
|
||||||
"-s",
|
"-s",
|
||||||
help="Stream responses in real-time",
|
help="Stream responses in real-time (default from config)",
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -564,6 +625,11 @@ def explore(
|
|||||||
|
|
||||||
Runs the Task agent in plan (read-only) mode for a single query.
|
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("[dim]Note: 'explore' is deprecated. Use 'chat --mode plan' for interactive mode.[/]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
@@ -573,7 +639,7 @@ def explore(
|
|||||||
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
||||||
raise typer.Exit(1)
|
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(
|
async def _explore(
|
||||||
|
|||||||
Reference in New Issue
Block a user