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:
425
cli.py
Normal file
425
cli.py
Normal 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()
|
||||
Reference in New Issue
Block a user