mod ai;
mod api;
mod config;
mod core;
mod credentials;
mod db;
mod paths;
mod sync;
use anyhow::Result;
use clap::{Parser, Subcommand};
use tracing_subscriber::{EnvFilter, fmt};
#[derive(Parser)]
#[command(name = "asurada", version, about = "Asurada — your AI partner that grows with you")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Init,
Serve {
#[arg(long, default_value_t = 7878)]
port: u16,
},
Path,
#[command(subcommand)]
Sync(SyncCmd),
#[command(subcommand)]
Tts(TtsCmd),
}
#[derive(Subcommand)]
enum TtsCmd {
On,
Off,
Status,
Voices,
Test {
text: String,
},
Speak {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value_t = 1)]
limit: usize,
},
}
#[derive(Subcommand)]
enum SyncCmd {
Push,
Pull,
Run,
}
#[tokio::main]
async fn main() -> Result<()> {
fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let cli = Cli::parse();
match cli.cmd {
Cmd::Init => cmd_init().await,
Cmd::Serve { port } => cmd_serve(port).await,
Cmd::Path => cmd_path(),
Cmd::Sync(sub) => match sub {
SyncCmd::Push => cmd_sync_push().await,
SyncCmd::Pull => cmd_sync_pull().await,
SyncCmd::Run => cmd_sync_run().await,
},
Cmd::Tts(sub) => match sub {
TtsCmd::On => cmd_tts_set(true).await,
TtsCmd::Off => cmd_tts_set(false).await,
TtsCmd::Status => cmd_tts_status().await,
TtsCmd::Voices => cmd_tts_voices().await,
TtsCmd::Test { text } => cmd_tts_test(text).await,
TtsCmd::Speak { project, limit } => cmd_tts_speak(project, limit).await,
},
}
}
fn elevenlabs_key() -> Result<String> {
std::env::var("ASURADA_ELEVENLABS_API_KEY").map_err(|_| {
anyhow::anyhow!(
"ASURADA_ELEVENLABS_API_KEY 환경 변수 필요.\n\
https://elevenlabs.io 에서 발급 후:\n\
\texport ASURADA_ELEVENLABS_API_KEY=..."
)
})
}
fn load_config() -> Result<config::Config> {
config::Config::load_or_default(&paths::config_file()?)
}
async fn cmd_tts_set(enabled: bool) -> Result<()> {
let mut cfg = load_config()?;
cfg.tts.enabled = enabled;
cfg.save(&paths::config_file()?)?;
println!("TTS {}", if enabled { "ON" } else { "OFF" });
Ok(())
}
async fn cmd_tts_status() -> Result<()> {
let cfg = load_config()?;
println!("TTS: {}", if cfg.tts.enabled { "ON" } else { "OFF" });
println!(
"API key: {}",
if std::env::var("ASURADA_ELEVENLABS_API_KEY").is_ok() {
"set"
} else {
"not set"
}
);
println!(
"Voice ID: {}",
cfg.tts
.voice_id
.or_else(|| std::env::var("ASURADA_ELEVENLABS_VOICE_ID").ok())
.unwrap_or_else(|| "(not configured)".into())
);
Ok(())
}
async fn cmd_tts_voices() -> Result<()> {
let key = elevenlabs_key()?;
let client = ai::elevenlabs::ElevenLabsClient::new(key)?;
let voices = client.list_voices().await?;
println!("ElevenLabs voices:");
for v in &voices {
println!(" {:32} {}", v.name, v.voice_id);
}
println!();
println!(
"Set with: ASURADA_ELEVENLABS_VOICE_ID=<voice_id> 또는 config.toml [tts] voice_id"
);
Ok(())
}
async fn cmd_tts_test(text: String) -> Result<()> {
let key = elevenlabs_key()?;
let cfg = load_config()?;
let voice_id = cfg
.tts
.voice_id
.or_else(|| std::env::var("ASURADA_ELEVENLABS_VOICE_ID").ok())
.ok_or_else(|| anyhow::anyhow!("voice_id 가 설정되지 않음. `asurada tts voices` 로 조회 후 설정."))?;
let client = ai::elevenlabs::ElevenLabsClient::new(key)?;
println!("[TTS] {}", text);
client.speak(&text, &voice_id).await?;
Ok(())
}
async fn cmd_tts_speak(project: Option<String>, limit: usize) -> Result<()> {
let cfg = load_config()?;
if !cfg.tts.enabled {
println!("TTS OFF — `asurada tts on` 으로 활성화하세요.");
return Ok(());
}
let key = elevenlabs_key()?;
let voice_id = cfg
.tts
.voice_id
.clone()
.or_else(|| std::env::var("ASURADA_ELEVENLABS_VOICE_ID").ok())
.ok_or_else(|| anyhow::anyhow!("voice_id 미설정. `asurada tts voices`"))?;
let user_id = std::env::var("ASURADA_USER_ID").unwrap_or_else(|_| "default".into());
let brain_path = paths::brain_db()?;
let conn = db::open(&brain_path)?;
let pending = db::advice::list_pending(&conn, &user_id, project.as_deref(), limit)?;
if pending.is_empty() {
println!("읽을 미확인 어드바이스가 없습니다.");
return Ok(());
}
let client = ai::elevenlabs::ElevenLabsClient::new(key)?;
for adv in &pending {
let speech = ai::speech::condense_for_speech(&adv.text, &adv.severity, &adv.project);
println!("[{}] {} {}", adv.severity, adv.project, speech);
if let Err(e) = client.speak(&speech, &voice_id).await {
tracing::warn!("speak failed: {}", e);
}
}
Ok(())
}
async fn open_sync() -> Result<sync::Sync> {
use std::sync::{Arc, Mutex};
let creds = credentials::Credentials::require_env()?;
let brain_path = paths::brain_db()?;
let conn = db::open(&brain_path)?;
let brain = Arc::new(Mutex::new(conn));
sync::Sync::connect(brain, &creds).await
}
async fn cmd_sync_push() -> Result<()> {
let s = open_sync().await?;
let p = s.push_all().await?;
println!(
"push events={} memories={} advice={} projects={}",
p.events, p.memories, p.advice, p.projects
);
Ok(())
}
async fn cmd_sync_pull() -> Result<()> {
let s = open_sync().await?;
let p = s.pull_all().await?;
println!(
"pull events={} memories={} advice={} projects={}",
p.events, p.memories, p.advice, p.projects
);
Ok(())
}
async fn cmd_sync_run() -> Result<()> {
let s = open_sync().await?;
let pu = s.push_all().await?;
let pl = s.pull_all().await?;
println!(
"push events={} memories={} advice={} projects={}",
pu.events, pu.memories, pu.advice, pu.projects
);
println!(
"pull events={} memories={} advice={} projects={}",
pl.events, pl.memories, pl.advice, pl.projects
);
Ok(())
}
async fn cmd_init() -> Result<()> {
let brain = paths::brain_db()?;
if let Some(parent) = brain.parent() {
std::fs::create_dir_all(parent)?;
}
let _conn = db::open(&brain)?;
tracing::info!("brain.db ready at {}", brain.display());
println!("아스라다가 깨어났습니다 🌟");
println!(" brain.db: {}", brain.display());
Ok(())
}
async fn cmd_serve(port: u16) -> Result<()> {
use std::sync::{Arc, Mutex};
use std::time::Duration;
let brain_path = paths::brain_db()?;
if let Some(parent) = brain_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = db::open(&brain_path)?;
let brain = Arc::new(Mutex::new(conn));
if let Some(creds) = credentials::Credentials::from_env() {
match sync::Sync::connect(brain.clone(), &creds).await {
Ok(s) => {
tokio::spawn(s.run_loop(Duration::from_secs(30)));
}
Err(e) => {
tracing::warn!("[sync] disabled — connect failed: {}", e);
}
}
} else {
tracing::warn!(
"[sync] disabled — set ASURADA_DATABASE_URL and ASURADA_USER_ID to enable cloud sync"
);
}
let state = api::AppState { conn: brain };
let app = api::router(state);
let addr = format!("127.0.0.1:{}", port);
tracing::info!("listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn cmd_path() -> Result<()> {
println!("config_dir: {}", paths::config_dir()?.display());
println!("brain_db: {}", paths::brain_db()?.display());
Ok(())
}