- 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
398 lines
11 KiB
Python
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()
|