Files
Aetheel/cli.py
tanmay11k 82c2640481 feat: openclaw-style secrets (env.vars + \) and per-task model routing
- Replace python-dotenv with config.json env.vars block + \ substitution
- Add models section for per-task model routing (heartbeat, subagent, default)
- Heartbeat/subagent tasks can use different models/providers than main chat
- Remove python-dotenv from dependencies
- Update all docs to reflect new config approach
- Reorganize docs into project/ and research/ subdirectories
2026-02-20 23:49:05 -05:00

398 lines
11 KiB
Python

#!/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
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."""
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."""
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."""
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
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
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
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() # also applies env.vars to process env
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()