feat: config-driven architecture, install wizard, live runtime switching, usage tracking, auto-failover

Major changes:
- Config-driven adapters: all channels (Slack, Discord, Telegram, WebChat, Webhooks) controlled via config.json with enabled flags and token auto-detection, no CLI flags required
- Runtime engine field: runtime.engine selects opencode/claude from config
- Interactive install script: 8-phase setup wizard with AI runtime detection/installation, token setup, identity file personalization (personality presets), aetheel CLI command, background service (launchd/systemd)
- Live runtime switching: /engine, /model, /provider commands hot-swap the AI runtime from chat without restart, changes persisted to config.json
- Usage tracking: per-request cost extraction from Claude Code JSON output, cumulative stats via /usage command
- Auto-failover: rate limit detection on both runtimes, automatic switch to other engine on quota errors with user notification
- Chat commands work without / prefix (Slack intercepts / in channels), commands: engine, model, provider, config, usage, reload, cron, subagents, status, help
- /config set for editing config.json from chat with dotted key notation
- Security audit saved to docs/security-audit.md
- Full command reference in docs/commands.md
- Future changes doc with NanoClaw agent teams analysis
- Logo added to README and WebChat UI
- README fully rewritten with all features documented
This commit is contained in:
2026-02-18 01:07:12 -05:00
parent 41b2f9a593
commit 6d73f74e0b
41 changed files with 11363 additions and 437 deletions

425
cli.py Normal file
View File

@@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""
Aetheel CLI
===========
Click-based command-line interface for Aetheel.
Usage:
aetheel Start with default adapters
aetheel start --discord Start with Discord adapter
aetheel chat "Hello" One-shot AI query
aetheel status Show runtime status
aetheel doctor Run diagnostics
aetheel config show Show current config
aetheel cron list List scheduled jobs
aetheel memory search "q" Search memory
"""
import json
import os
import subprocess
import sys
import click
from config import (
CONFIG_PATH,
AetheelConfig,
load_config,
save_default_config,
write_mcp_config,
)
@click.group(invoke_without_command=True)
@click.pass_context
def cli(ctx):
"""Aetheel — AI-Powered Personal Assistant"""
if ctx.invoked_subcommand is None:
ctx.invoke(start)
@cli.command()
@click.option("--discord", is_flag=True, help="Override: enable Discord adapter")
@click.option("--telegram", is_flag=True, help="Override: enable Telegram adapter")
@click.option("--webchat", is_flag=True, help="Override: enable WebChat adapter")
@click.option("--claude", is_flag=True, help="Override: use Claude Code runtime")
@click.option("--model", default=None, help="Override: model to use")
@click.option("--test", is_flag=True, help="Use echo handler for testing")
@click.option("--log", default="INFO", help="Log level")
def start(discord, telegram, webchat, claude, model, test, log):
"""Start Aetheel (all settings from config, flags are optional overrides)."""
# Build sys.argv equivalent and delegate to main.main()
argv = ["main.py"]
if discord:
argv.append("--discord")
if telegram:
argv.append("--telegram")
if webchat:
argv.append("--webchat")
if claude:
argv.append("--claude")
if model:
argv.extend(["--model", model])
if test:
argv.append("--test")
if log and log != "INFO":
argv.extend(["--log", log])
# Patch sys.argv so argparse inside main.main() sees our flags
old_argv = sys.argv
sys.argv = argv
try:
from main import main
main()
finally:
sys.argv = old_argv
@cli.command()
@click.argument("message")
def chat(message):
"""One-shot chat with the AI."""
import logging
from dotenv import load_dotenv
load_dotenv()
save_default_config()
cfg = load_config()
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
# Write MCP config if needed
write_mcp_config(cfg.mcp, cfg.memory.workspace, False)
# Initialize runtime
from agent.opencode_runtime import (
OpenCodeConfig,
OpenCodeRuntime,
RuntimeMode,
build_aetheel_system_prompt,
)
oc_config = OpenCodeConfig(
mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI,
server_url=cfg.runtime.server_url,
timeout_seconds=cfg.runtime.timeout_seconds,
model=cfg.runtime.model,
provider=cfg.runtime.provider,
workspace_dir=cfg.runtime.workspace,
format=cfg.runtime.format,
)
runtime = OpenCodeRuntime(oc_config)
# Run one-shot query
system_prompt = build_aetheel_system_prompt()
response = runtime.chat(message, system_prompt=system_prompt)
click.echo(response.text if hasattr(response, "text") else str(response))
@cli.command()
def status():
"""Show runtime status."""
from dotenv import load_dotenv
load_dotenv()
save_default_config()
cfg = load_config()
click.echo("🟢 Aetheel Status")
click.echo()
click.echo(f" Config: {CONFIG_PATH}")
click.echo(f" Runtime: {cfg.runtime.mode}, model={cfg.runtime.model or 'default'}")
click.echo(f" Workspace: {cfg.memory.workspace}")
click.echo(f" Heartbeat: {'enabled' if cfg.heartbeat.enabled else 'disabled'}")
click.echo(f" WebChat: {'enabled' if cfg.webchat.enabled else 'disabled'}")
# Show scheduler jobs if possible
try:
from scheduler import Scheduler
sched = Scheduler(callback=lambda j: None)
sched.start()
jobs = sched.list_jobs()
click.echo(f" Jobs: {len(jobs)}")
sched.stop()
except Exception:
click.echo(" Jobs: (scheduler unavailable)")
# ---------------------------------------------------------------------------
# cron group
# ---------------------------------------------------------------------------
@cli.group()
def cron():
"""Manage scheduled jobs."""
pass
@cron.command("list")
def cron_list():
"""List scheduled jobs."""
from dotenv import load_dotenv
load_dotenv()
save_default_config()
try:
from scheduler import Scheduler
sched = Scheduler(callback=lambda j: None)
sched.start()
jobs = sched.list_jobs()
if not jobs:
click.echo("No scheduled jobs.")
else:
for job in jobs:
click.echo(
f" [{job.job_id}] {job.job_type}{job.prompt[:60]}"
)
sched.stop()
except Exception as e:
click.echo(f"Error listing jobs: {e}")
@cron.command("remove")
@click.argument("job_id")
def cron_remove(job_id):
"""Remove a scheduled job."""
from dotenv import load_dotenv
load_dotenv()
save_default_config()
try:
from scheduler import Scheduler
sched = Scheduler(callback=lambda j: None)
sched.start()
sched.remove_job(job_id)
click.echo(f"Removed job {job_id}")
sched.stop()
except Exception as e:
click.echo(f"Error removing job: {e}")
# ---------------------------------------------------------------------------
# config group
# ---------------------------------------------------------------------------
@cli.group()
def config():
"""Manage configuration."""
pass
@config.command("show")
def config_show():
"""Show current configuration."""
if os.path.isfile(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
click.echo(f.read())
else:
click.echo(f"No config file found at {CONFIG_PATH}")
click.echo("Run 'aetheel config init' to create one.")
@config.command("edit")
def config_edit():
"""Open config in editor."""
save_default_config()
editor = os.environ.get("EDITOR", "nano")
try:
subprocess.run([editor, CONFIG_PATH], check=True)
except FileNotFoundError:
click.echo(f"Editor '{editor}' not found. Set $EDITOR or edit manually:")
click.echo(f" {CONFIG_PATH}")
except subprocess.CalledProcessError as e:
click.echo(f"Editor exited with error: {e}")
@config.command("init")
def config_init():
"""Reset config to defaults."""
# Force-write by removing existing file first
if os.path.isfile(CONFIG_PATH):
os.remove(CONFIG_PATH)
click.echo(f"Removed existing config at {CONFIG_PATH}")
path = save_default_config()
click.echo(f"Default config written to {path}")
# ---------------------------------------------------------------------------
# memory group
# ---------------------------------------------------------------------------
@cli.group()
def memory():
"""Manage memory system."""
pass
@memory.command("search")
@click.argument("query")
def memory_search(query):
"""Search memory."""
import asyncio
import logging
from dotenv import load_dotenv
load_dotenv()
save_default_config()
cfg = load_config()
logging.basicConfig(level=logging.WARNING)
try:
from memory import MemoryManager
from memory.types import MemoryConfig as MemConfig
workspace_dir = os.path.expanduser(cfg.memory.workspace)
db_path = os.path.expanduser(cfg.memory.db_path)
mem_config = MemConfig(workspace_dir=workspace_dir, db_path=db_path)
mem = MemoryManager(mem_config)
results = asyncio.run(mem.search(query))
if not results:
click.echo("No results found.")
else:
for r in results:
score = getattr(r, "score", "?")
text = getattr(r, "text", str(r))
click.echo(f" [{score}] {text[:120]}")
mem.close()
except Exception as e:
click.echo(f"Memory search error: {e}")
@memory.command("sync")
def memory_sync():
"""Force memory re-index."""
import asyncio
import logging
from dotenv import load_dotenv
load_dotenv()
save_default_config()
cfg = load_config()
logging.basicConfig(level=logging.WARNING)
try:
from memory import MemoryManager
from memory.types import MemoryConfig as MemConfig
workspace_dir = os.path.expanduser(cfg.memory.workspace)
db_path = os.path.expanduser(cfg.memory.db_path)
mem_config = MemConfig(workspace_dir=workspace_dir, db_path=db_path)
mem = MemoryManager(mem_config)
stats = asyncio.run(mem.sync())
click.echo(
f"Sync complete: {stats.get('files_indexed', 0)} files, "
f"{stats.get('chunks_created', 0)} chunks"
)
mem.close()
except Exception as e:
click.echo(f"Memory sync error: {e}")
# ---------------------------------------------------------------------------
# doctor command
# ---------------------------------------------------------------------------
@cli.command()
def doctor():
"""Run diagnostics."""
import shutil
from dotenv import load_dotenv
load_dotenv()
click.echo("🩺 Aetheel Doctor")
click.echo()
# Check config
if os.path.isfile(CONFIG_PATH):
click.echo(f" ✅ Config file: {CONFIG_PATH}")
try:
with open(CONFIG_PATH, "r") as f:
json.load(f)
click.echo(" ✅ Config JSON is valid")
except json.JSONDecodeError as e:
click.echo(f" ❌ Config JSON invalid: {e}")
else:
click.echo(f" ⚠️ No config file at {CONFIG_PATH}")
# Check workspace
cfg = load_config()
workspace = os.path.expanduser(cfg.memory.workspace)
if os.path.isdir(workspace):
click.echo(f" ✅ Workspace: {workspace}")
else:
click.echo(f" ⚠️ Workspace missing: {workspace}")
# Check runtimes
if shutil.which("claude"):
click.echo(" ✅ Claude Code CLI found")
else:
click.echo(" ⚠️ Claude Code CLI not found")
if shutil.which("opencode"):
click.echo(" ✅ OpenCode CLI found")
else:
click.echo(" ⚠️ OpenCode CLI not found")
# Check tokens
tokens = {
"SLACK_BOT_TOKEN": os.environ.get("SLACK_BOT_TOKEN"),
"SLACK_APP_TOKEN": os.environ.get("SLACK_APP_TOKEN"),
"TELEGRAM_BOT_TOKEN": os.environ.get("TELEGRAM_BOT_TOKEN"),
"DISCORD_BOT_TOKEN": os.environ.get("DISCORD_BOT_TOKEN"),
}
click.echo()
click.echo(" Tokens:")
for name, val in tokens.items():
if val:
click.echo(f"{name} is set")
else:
click.echo(f" ⚠️ {name} not set")
# Check memory DB
db_path = os.path.expanduser(cfg.memory.db_path)
if os.path.isfile(db_path):
click.echo(f" ✅ Memory DB: {db_path}")
else:
click.echo(f" ⚠️ Memory DB missing: {db_path}")
click.echo()
click.echo("Done.")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
cli()