mod adapter;
mod ai;
mod api;
mod config;
mod core;
mod credentials;
mod db;
mod detect;
mod hook;
mod paths;
mod pattern;
mod signals;
mod sync;
mod synthesis;
mod voice;
use anyhow::Result;
use clap::{Parser, Subcommand};
use tracing_subscriber::{fmt, EnvFilter};
#[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,
Onboard,
Serve {
#[arg(long, default_value_t = 7878)]
port: u16,
#[arg(long, default_value_t = 300)]
auto_pattern_interval: u64,
#[arg(long, default_value_t = 2)]
auto_evolution_threshold: usize,
#[arg(long, default_value_t = 3)]
auto_redo_threshold: usize,
},
Path,
#[command(subcommand)]
Sync(SyncCmd),
#[command(subcommand)]
Tts(TtsCmd),
#[command(subcommand)]
Config(ConfigCmd),
#[command(subcommand)]
Hook(HookCmd),
#[command(subcommand)]
Signals(SignalsCmd),
#[command(subcommand)]
Intent(IntentCmd),
#[command(subcommand)]
Detect(DetectCmd),
#[command(subcommand)]
Advice(AdviceCmd),
#[command(subcommand)]
Pattern(PatternCmd),
#[command(subcommand)]
Admin(AdminCmd),
#[command(subcommand)]
Voice(VoiceCmd),
Brief {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value_t = 1)]
days: i64,
},
Reflect {
#[arg(long, default_value_t = 1)]
days: i64,
},
#[command(subcommand)]
Issue(IssueCmd),
#[command(subcommand)]
Memory(MemoryCmd),
}
#[derive(Subcommand)]
enum MemoryCmd {
List {
#[arg(long)]
scope: Option<String>,
#[arg(long, default_value_t = 20)]
limit: usize,
},
Get { id: String },
Search {
query: String,
#[arg(long, default_value_t = 20)]
limit: usize,
},
}
#[derive(Subcommand)]
enum IssueCmd {
Capture {
#[arg(long, default_value_t = 24)]
hours: i64,
},
List {
#[arg(long, default_value_t = 20)]
limit: usize,
},
Get { id: String },
}
#[derive(Subcommand)]
enum VoiceCmd {
Greet {
#[arg(long)]
dry_run: bool,
},
Survey,
Status,
}
#[derive(Subcommand)]
enum AdminCmd {
NormalizeEvents {
#[arg(long)]
apply: bool,
},
}
#[derive(Subcommand)]
enum PatternCmd {
Propose {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value_t = 3)]
threshold: usize,
#[arg(long, default_value_t = 30)]
days: i64,
},
Apply {
#[arg(long)]
signature: i64,
#[arg(long)]
project: String,
#[arg(long)]
force: bool,
},
List {
#[arg(long)]
project: String,
},
Get { id: String },
EvolveScan {
#[arg(long, default_value_t = 2)]
intervention_threshold: usize,
#[arg(long, default_value_t = 30)]
days: i64,
#[arg(long)]
propose: bool,
},
}
#[derive(Subcommand)]
enum DetectCmd {
Scan {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value_t = 3)]
threshold: usize,
#[arg(long, default_value_t = 30)]
days: i64,
},
Propose {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value_t = 3)]
threshold: usize,
#[arg(long, default_value_t = 30)]
days: i64,
},
}
#[derive(Subcommand)]
enum AdviceCmd {
List {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value_t = 20)]
limit: usize,
},
Get { id: String },
Promote { id: String },
Dismiss { id: String },
}
#[derive(Subcommand)]
enum IntentCmd {
Add {
#[arg(long)]
strength: String,
text: String,
#[arg(long)]
project: Option<String>,
#[arg(long)]
tool: Option<String>,
#[arg(long)]
contains: Option<String>,
#[arg(long, default_value = "ask")]
decision: String,
},
List {
#[arg(long)]
project: Option<String>,
#[arg(long)]
strength: Option<String>,
#[arg(long)]
all: bool,
},
Get { id: String },
Archive { id: String },
Compile {
#[arg(long)]
dry_run: bool,
#[arg(long)]
project: Option<String>,
},
}
#[derive(Subcommand)]
enum SignalsCmd {
Recent {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value_t = 20)]
limit: usize,
},
Summary {
#[arg(long)]
project: Option<String>,
},
}
#[derive(Subcommand)]
enum HookCmd {
Check { event: String },
Install,
Uninstall,
Status,
}
#[derive(Subcommand)]
enum ConfigCmd {
Show,
Path,
Set { key: String, value: String },
}
#[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::Onboard => cmd_onboard(),
Cmd::Serve {
port,
auto_pattern_interval,
auto_evolution_threshold,
auto_redo_threshold,
} => {
cmd_serve(
port,
auto_pattern_interval,
auto_evolution_threshold,
auto_redo_threshold,
)
.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,
},
Cmd::Config(sub) => match sub {
ConfigCmd::Show => cmd_config_show().await,
ConfigCmd::Path => cmd_config_path(),
ConfigCmd::Set { key, value } => cmd_config_set(key, value).await,
},
Cmd::Hook(sub) => match sub {
HookCmd::Check { event } => {
let evt = hook::HookEvent::parse(&event).ok_or_else(|| {
anyhow::anyhow!(
"unknown hook event: {} \
(expected pre-tool-use|post-tool-use|user-prompt-submit|stop|session-start|session-end)",
event
)
})?;
hook::cmd_check(evt)
}
HookCmd::Install => cmd_hook_install(),
HookCmd::Uninstall => cmd_hook_uninstall(),
HookCmd::Status => cmd_hook_status(),
},
Cmd::Signals(sub) => match sub {
SignalsCmd::Recent { project, limit } => cmd_signals_recent(project, limit),
SignalsCmd::Summary { project } => cmd_signals_summary(project),
},
Cmd::Intent(sub) => match sub {
IntentCmd::Add {
strength,
text,
project,
tool,
contains,
decision,
} => cmd_intent_add(strength, text, project, tool, contains, decision),
IntentCmd::List {
project,
strength,
all,
} => cmd_intent_list(project, strength, all),
IntentCmd::Get { id } => cmd_intent_get(id),
IntentCmd::Archive { id } => cmd_intent_archive(id),
IntentCmd::Compile { dry_run, project } => cmd_intent_compile(dry_run, project),
},
Cmd::Detect(sub) => match sub {
DetectCmd::Scan {
project,
threshold,
days,
} => cmd_detect_scan(project, threshold, days, false),
DetectCmd::Propose {
project,
threshold,
days,
} => cmd_detect_scan(project, threshold, days, true),
},
Cmd::Advice(sub) => match sub {
AdviceCmd::List { project, limit } => cmd_advice_list(project, limit),
AdviceCmd::Get { id } => cmd_advice_get(id),
AdviceCmd::Promote { id } => cmd_advice_promote(id),
AdviceCmd::Dismiss { id } => cmd_advice_dismiss(id),
},
Cmd::Admin(sub) => match sub {
AdminCmd::NormalizeEvents { apply } => cmd_admin_normalize_events(apply),
},
Cmd::Voice(sub) => match sub {
VoiceCmd::Greet { dry_run } => cmd_voice_greet(dry_run).await,
VoiceCmd::Survey => cmd_voice_survey(),
VoiceCmd::Status => cmd_voice_status(),
},
Cmd::Brief { project, days } => cmd_brief(project, days).await,
Cmd::Reflect { days } => cmd_reflect(days).await,
Cmd::Issue(sub) => match sub {
IssueCmd::Capture { hours } => cmd_issue_capture(hours).await,
IssueCmd::List { limit } => cmd_issue_list(limit),
IssueCmd::Get { id } => cmd_issue_get(id),
},
Cmd::Memory(sub) => match sub {
MemoryCmd::List { scope, limit } => cmd_memory_list(scope, limit),
MemoryCmd::Get { id } => cmd_memory_get(id),
MemoryCmd::Search { query, limit } => cmd_memory_search(query, limit),
},
Cmd::Pattern(sub) => match sub {
PatternCmd::Propose {
project,
threshold,
days,
} => cmd_pattern_propose(project, threshold, days),
PatternCmd::Apply {
signature,
project,
force,
} => cmd_pattern_apply(signature, project, force),
PatternCmd::List { project } => cmd_pattern_list(project),
PatternCmd::Get { id } => cmd_pattern_get(id),
PatternCmd::EvolveScan {
intervention_threshold,
days,
propose,
} => cmd_pattern_evolve_scan(intervention_threshold, days, propose),
},
}
}
fn cmd_hook_install() -> Result<()> {
let report = hook::settings::install()?;
println!("✓ hook 등록 완료");
println!(" settings.json: {}", report.path.display());
println!(" binary: {}", report.bin);
println!(" events: {}", report.events.join(", "));
println!();
println!("Phase 1 passthrough — Claude Code 의 모든 도구 호출/프롬프트가");
println!("brain.db 의 events 테이블에 기록됩니다 (차단 없음).");
println!();
println!("새 Claude Code 세션부터 적용됩니다.");
Ok(())
}
fn cmd_hook_uninstall() -> Result<()> {
let report = hook::settings::uninstall()?;
if report.removed.is_empty() {
println!(
"등록된 asurada hook 이 없습니다 ({}).",
report.path.display()
);
} else {
println!("✓ hook 제거: {}", report.removed.join(", "));
println!(" settings.json: {}", report.path.display());
}
Ok(())
}
fn cmd_hook_status() -> Result<()> {
let path = hook::settings::settings_path()?;
let active = hook::settings::status()?;
if active.is_empty() {
println!("hook 미등록 ({})", path.display());
println!(" 설치: asurada hook install");
} else {
println!("hook 등록됨 ({})", path.display());
println!(" events: {}", active.join(", "));
}
Ok(())
}
fn cmd_signals_recent(project: Option<String>, limit: usize) -> Result<()> {
use rusqlite::params;
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let sql = if project.is_some() {
r#"SELECT created_at, event_type, project, payload
FROM events
WHERE user_id = ?1 AND project = ?2
AND event_type IN ('signal.intervention', 'signal.redo')
ORDER BY created_at DESC LIMIT ?3"#
} else {
r#"SELECT created_at, event_type, project, payload
FROM events
WHERE user_id = ?1
AND event_type IN ('signal.intervention', 'signal.redo')
ORDER BY created_at DESC LIMIT ?2"#
};
let mut stmt = conn.prepare(sql)?;
let rows: Vec<(String, String, String, String)> = if let Some(p) = project.as_deref() {
stmt.query_map(params![&cfg.user.id, p, limit as i64], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
})?
.filter_map(|r| r.ok())
.collect()
} else {
stmt.query_map(params![&cfg.user.id, limit as i64], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
})?
.filter_map(|r| r.ok())
.collect()
};
if rows.is_empty() {
println!("(누적된 신호 없음)");
return Ok(());
}
for (created, kind, proj, payload) in rows {
let pv: serde_json::Value =
serde_json::from_str(&payload).unwrap_or(serde_json::Value::Null);
let detail = match kind.as_str() {
"signal.intervention" => format!(
"patterns={}",
pv.get("patterns")
.map(|v| v.to_string())
.unwrap_or_default()
),
"signal.redo" => format!(
"prior={} | {}",
pv.get("prior_count").and_then(|v| v.as_u64()).unwrap_or(0),
pv.get("prompt_preview")
.and_then(|v| v.as_str())
.unwrap_or("")
),
_ => "".into(),
};
println!("{} [{}] {:24} {}", created, proj, kind, detail);
}
Ok(())
}
fn cmd_intent_add(
strength: String,
text: String,
project: Option<String>,
tool: Option<String>,
contains: Option<String>,
decision: String,
) -> Result<()> {
let s = db::intent::Strength::parse(&strength).ok_or_else(|| {
anyhow::anyhow!(
"strength 는 preference|principle|context 중 하나여야 합니다 (입력: {})",
strength
)
})?;
if !matches!(decision.as_str(), "ask" | "deny" | "allow") {
anyhow::bail!(
"decision 은 ask|deny|allow 중 하나여야 합니다 (입력: {})",
decision
);
}
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let mut metadata = serde_json::json!({});
if s == db::intent::Strength::Principle && (tool.is_some() || contains.is_some()) {
let mut trigger = serde_json::Map::new();
if let Some(t) = tool {
trigger.insert("tool".into(), serde_json::Value::String(t));
}
if let Some(c) = contains {
trigger.insert("contains".into(), serde_json::Value::String(c));
}
metadata["trigger"] = serde_json::Value::Object(trigger);
metadata["decision"] = serde_json::Value::String(decision);
}
let intent = db::intent::insert(
&conn,
db::intent::IntentInput {
user_id: cfg.user.id,
project,
strength: s,
intent_text: text,
source: db::intent::Source::User,
source_signal_ids: vec![],
metadata,
},
)?;
println!("✓ intent 추가됨: {}", intent.id);
println!(
" strength: {} ({})",
s.as_str(),
adapter::strength_label(s)
);
println!(" text: {}", intent.intent_text);
if let Some(p) = &intent.project {
println!(" project: {}", p);
} else {
println!(" project: (전역)");
}
println!();
println!("적용하려면: asurada intent compile");
Ok(())
}
fn cmd_intent_list(project: Option<String>, strength: Option<String>, all: bool) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let strength_filter = strength.as_deref().and_then(db::intent::Strength::parse);
let intents = if all {
db::intent::list_all(&conn, &cfg.user.id)?
} else {
db::intent::list_active(&conn, &cfg.user.id, project.as_deref(), strength_filter)?
};
if intents.is_empty() {
println!("(intent 없음)");
return Ok(());
}
for it in intents {
let scope = it.project.as_deref().unwrap_or("(전역)");
println!(
"{:8} {:10} {:14} {}",
&it.id[..8.min(it.id.len())],
it.strength.as_str(),
scope,
it.intent_text.trim()
);
}
Ok(())
}
fn cmd_intent_get(id: String) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let all = db::intent::list_all(&conn, &cfg.user.id)?;
let found = all.iter().find(|i| i.id.starts_with(&id));
let Some(it) = found else {
anyhow::bail!("intent 없음: {}", id);
};
println!("id: {}", it.id);
println!(
"strength: {} ({})",
it.strength.as_str(),
adapter::strength_label(it.strength)
);
println!("status: {}", it.status.as_str());
println!("project: {}", it.project.as_deref().unwrap_or("(전역)"));
println!("source: {}", it.source.as_str());
println!("created: {}", it.created_at);
println!("text:");
println!(" {}", it.intent_text);
if !it.metadata.is_null() && it.metadata != serde_json::json!({}) {
println!("metadata:");
println!(" {}", serde_json::to_string_pretty(&it.metadata)?);
}
Ok(())
}
fn cmd_intent_archive(id: String) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let all = db::intent::list_all(&conn, &cfg.user.id)?;
let found = all.iter().find(|i| i.id.starts_with(&id));
let Some(it) = found else {
anyhow::bail!("intent 없음: {}", id);
};
db::intent::set_status(&conn, &it.id, db::intent::Status::Archived)?;
println!("✓ intent 보관됨: {}", it.id);
println!(" 적용 중지하려면: asurada intent compile");
Ok(())
}
fn cmd_intent_compile(dry_run: bool, project: Option<String>) -> Result<()> {
use adapter::RuntimeAdapter;
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let intents = db::intent::list_active(&conn, &cfg.user.id, project.as_deref(), None)?;
let patterns = db::pattern::list(&conn, &cfg.user.id, project.as_deref())?;
if intents.is_empty() && patterns.is_empty() {
println!("(활성 intent / 패턴 없음 — 컴파일할 게 없음)");
return Ok(());
}
let ad = adapter::claude_code::ClaudeCodeAdapter::default()?;
let artifacts = ad.compile(&intents, &patterns)?;
println!("─ 컴파일 결과 ─────────────────────────");
for block in &artifacts.text_blocks {
let target_label = match &block.target {
adapter::BlockTarget::GlobalClaudeMd => ad.global_claude_md.display().to_string(),
adapter::BlockTarget::ProjectClaudeMd(p) => p.display().to_string(),
};
let line_count = block.body.lines().count();
println!(
"[block {} ({} 줄) → {}]",
block.id, line_count, target_label
);
for line in block.body.lines() {
println!(" {}", line);
}
println!();
}
for gf in &artifacts.files {
let line_count = gf.body.lines().count();
println!("[file ({} 줄) → {}]", line_count, gf.path.display());
println!();
}
if !artifacts.gate_rules.is_empty() {
println!("[gate rules → {}]", ad.gates_path.display());
for r in &artifacts.gate_rules {
println!(
" - {} ({}{}) → {}",
r.reason,
r.tool.as_deref().unwrap_or("*"),
r.contains
.as_deref()
.map(|c| format!(" contains {:?}", c))
.unwrap_or_default(),
r.decision
);
}
println!();
}
if dry_run {
println!("(dry-run — 디스크에 쓰지 않음. 적용하려면 --dry-run 제외)");
return Ok(());
}
let report = ad.apply(&artifacts)?;
println!("─ 적용 완료 ────────────────────────────");
if !report.blocks_updated.is_empty() {
println!(" blocks: {}", report.blocks_updated.join(", "));
}
println!(" gate rules: {}", report.gate_rules_count);
println!(" files:");
for f in &report.files_written {
println!(" {}", f.display());
}
Ok(())
}
fn cmd_detect_scan(
project: Option<String>,
threshold: usize,
days: i64,
apply: bool,
) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let clusters =
detect::scan_redo_clusters(&conn, &cfg.user.id, project.as_deref(), days, threshold)?;
if clusters.is_empty() {
println!("(군집 없음 — threshold={}, days={})", threshold, days);
return Ok(());
}
println!("─ Redo 군집 ({}개) ─────────────────", clusters.len());
for c in &clusters {
let preview = c
.prompt_preview
.as_deref()
.map(|p| {
let s: String = p.chars().take(50).collect();
if p.chars().count() > 50 {
format!("{}…", s)
} else {
s
}
})
.unwrap_or_else(|| "(no preview)".into());
println!(
" [{}회] project={} last={} \"{}\"",
c.count, c.project, c.last_seen, preview
);
}
if !apply {
println!();
println!("실제로 advice 큐에 주입하려면: asurada pattern propose ...");
return Ok(());
}
let added = detect::propose_from_clusters(&conn, &cfg.user.id, &clusters)?;
let skipped = clusters.len() - added;
println!();
println!(
"✓ advice 추가: {} (skip={} — 이미 제안된 군집)",
added, skipped
);
if added > 0 {
println!("검토: asurada advice list");
}
Ok(())
}
fn cmd_advice_list(project: Option<String>, limit: usize) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let pending = db::advice::list_pending(&conn, &cfg.user.id, project.as_deref(), limit)?;
if pending.is_empty() {
println!("(미확인 advice 없음)");
return Ok(());
}
for a in pending {
let id_short = &a.id[..8.min(a.id.len())];
let preview: String = a.text.chars().take(70).collect();
let ellipsis = if a.text.chars().count() > 70 {
"…"
} else {
""
};
let has_proposal = a.metadata.get("proposed_intent").is_some();
let marker = if has_proposal { "[promotable]" } else { "" };
println!(
"{:8} [{}] {} {}{}",
id_short, a.severity, marker, preview, ellipsis
);
}
Ok(())
}
fn cmd_advice_get(id: String) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let all = db::advice::list_pending(&conn, &cfg.user.id, None, 1000)?;
let found = all.iter().find(|a| a.id.starts_with(&id));
let Some(a) = found else {
anyhow::bail!("advice 없음 (또는 이미 처리됨): {}", id);
};
println!("id: {}", a.id);
println!("project: {}", a.project);
println!("severity: {}", a.severity);
println!("state: {}", a.state);
println!("created: {}", a.created_at);
println!("text:");
for line in a.text.lines() {
println!(" {}", line);
}
if !a.metadata.is_null() && a.metadata != serde_json::json!({}) {
println!("metadata:");
println!(" {}", serde_json::to_string_pretty(&a.metadata)?);
}
if a.metadata.get("proposed_intent").is_some() {
println!();
println!("승격: asurada advice promote {}", a.id);
}
Ok(())
}
fn cmd_advice_promote(id: String) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let pending = db::advice::list_pending(&conn, &cfg.user.id, None, 1000)?;
let advice_id = pending
.iter()
.find(|a| a.id.starts_with(&id))
.map(|a| a.id.clone())
.ok_or_else(|| anyhow::anyhow!("미확인 advice 없음: {}", id))?;
match detect::promote_advice(&conn, &cfg.user.id, &advice_id)? {
detect::PromoteResult::NewIntent(it) => {
println!("✓ advice → intent 승격");
println!(" advice id: {}", advice_id);
println!(" intent id: {}", it.id);
println!(" strength: {}", it.strength.as_str());
println!(" text: {}", it.intent_text);
println!();
println!("적용: asurada intent compile");
}
detect::PromoteResult::PatternEvolved {
pattern_id,
skill_path,
} => {
println!("✓ 패턴 진화 적용");
println!(" advice id: {}", advice_id);
println!(" pattern id: {}", pattern_id);
println!(" 갱신 파일: {}", skill_path.display());
println!();
println!("진화 로그: asurada pattern get {}", pattern_id);
}
}
Ok(())
}
fn cmd_advice_dismiss(id: String) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let pending = db::advice::list_pending(&conn, &cfg.user.id, None, 1000)?;
let advice_id = pending
.iter()
.find(|a| a.id.starts_with(&id))
.map(|a| a.id.clone())
.ok_or_else(|| anyhow::anyhow!("미확인 advice 없음: {}", id))?;
db::advice::confirm(&conn, &cfg.user.id, &advice_id, "dismiss")?;
db::advice::set_state(&conn, &cfg.user.id, &advice_id, "done")?;
println!("✓ advice dismiss: {}", advice_id);
Ok(())
}
fn cmd_pattern_propose(project: Option<String>, threshold: usize, days: i64) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let clusters =
detect::scan_redo_clusters(&conn, &cfg.user.id, project.as_deref(), days, threshold)?;
if clusters.is_empty() {
println!("(군집 없음 — threshold={}, days={})", threshold, days);
return Ok(());
}
println!("─ 패턴 제안 ({}개) ─────────────────", clusters.len());
for c in &clusters {
let dummy_root = std::path::Path::new(".");
let p = pattern::propose_full_pattern(&conn, &cfg.user.id, c, dummy_root)?;
println!();
println!(
"[signature {}] project={} count={}",
c.signature, c.project, c.count
);
println!(" 제목: {}", p.title);
println!(" 슬러그: {}", p.slug);
println!(" 설명: {}", p.description);
println!(" 생성될 파일:");
println!(" {}", p.agent_path.display());
println!(" {}", p.skill_path.display());
println!(" {}", p.origin_ref_path.display());
if !p.action_summary.is_empty() {
println!(
" 과거 도구 사용 (top {}): {}",
p.action_summary.len().min(5),
p.action_summary
.iter()
.take(5)
.map(|(t, n)| format!("{}({})", t, n))
.collect::<Vec<_>>()
.join(", ")
);
}
println!(
" 적용: asurada pattern apply --signature {} --project {}",
c.signature, c.project
);
}
Ok(())
}
fn cmd_pattern_apply(signature: i64, project_name: String, force: bool) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let project_path = pattern::resolve_project_path(&conn, &cfg.user.id, &project_name)?;
let clusters = detect::scan_redo_clusters(&conn, &cfg.user.id, Some(&project_name), 365, 1)?;
let cluster = clusters
.into_iter()
.find(|c| c.signature == signature)
.ok_or_else(|| {
anyhow::anyhow!(
"signature {} 군집 없음 (project={}). asurada pattern propose 로 확인.",
signature,
project_name
)
})?;
let proposal = pattern::propose_full_pattern(&conn, &cfg.user.id, &cluster, &project_path)?;
let record = pattern::apply_full_pattern(&conn, &cfg.user.id, &project_path, &proposal, force)?;
println!("✓ Full pattern 생성 완료");
println!(" pattern id: {}", record.id);
println!(" 제목: {}", record.title);
println!(" 슬러그: {}", record.slug);
println!(" signature: {:016x}", proposal.trigger_signature as u64);
println!(" 파일:");
for f in &record.file_paths {
println!(" {}/{}", project_path.display(), f);
}
println!();
println!("다음 단계:");
println!(" - 다음 Claude Code 세션에서 이 skill+agent 가 자동 트리거 후보가 됩니다");
println!(" - 사용 시 사용량/진화 로그가 자동으로 누적됩니다");
println!(" - `asurada pattern get {}` 로 진화 로그 확인", record.id);
Ok(())
}
fn cmd_pattern_list(project_name: String) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let records = db::pattern::list(&conn, &cfg.user.id, Some(&project_name))?;
if records.is_empty() {
println!("(이 프로젝트에 Asurada 생성 패턴 없음)");
println!(" 생성: asurada pattern propose / apply");
return Ok(());
}
println!("{:8} {:14} {:6} title", "id", "slug", "uses");
for h in records {
let id_short = &h.id[..8.min(h.id.len())];
let slug_short: String = h.slug.chars().take(14).collect();
println!(
"{:8} {:14} {:6} {}",
id_short, slug_short, h.usage_count, h.title
);
}
Ok(())
}
fn cmd_pattern_evolve_scan(threshold: usize, days: i64, propose: bool) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let candidates =
detect::scan_pattern_evolution_candidates(&conn, &cfg.user.id, threshold, days)?;
if candidates.is_empty() {
println!("(진화 후보 없음 — threshold={}, days={})", threshold, days);
return Ok(());
}
println!("─ 진화 후보 ({}개) ─────────────────", candidates.len());
for c in &candidates {
println!();
println!(
" [{}] uses={} 교정={}",
c.pattern_title, c.use_count, c.intervention_count
);
println!(" pattern: {} ({})", c.pattern_id, c.pattern_slug);
if !c.recent_intervention_prompts.is_empty() {
println!(" 최근 교정 prompt:");
for p in c.recent_intervention_prompts.iter().take(3) {
let s: String = p.chars().take(60).collect();
println!(
" - \"{}{}\"",
s,
if p.chars().count() > 60 { "…" } else { "" }
);
}
}
}
if !propose {
println!();
println!("실제로 advice 큐에 주입하려면: --propose");
return Ok(());
}
let mut added = 0usize;
for c in &candidates {
if detect::already_proposed_evolution(&conn, &cfg.user.id, &c.pattern_id)? {
continue;
}
detect::propose_pattern_evolution(&conn, &cfg.user.id, c)?;
added += 1;
}
println!();
println!(
"✓ 진화 advice 주입: {} (skip={} — 미확인 진화 advice 이미 있음)",
added,
candidates.len() - added
);
if added > 0 {
println!("검토: asurada advice list");
}
Ok(())
}
fn cmd_pattern_get(id: String) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let all = db::pattern::list(&conn, &cfg.user.id, None)?;
let h = all
.into_iter()
.find(|h| h.id.starts_with(&id))
.ok_or_else(|| anyhow::anyhow!("패턴 없음: {}", id))?;
println!("id: {}", h.id);
println!("project: {}", h.project);
println!("slug: {}", h.slug);
println!("title: {}", h.title);
println!("description: {}", h.description);
println!("status: {}", h.status);
println!("usage_count: {}", h.usage_count);
if let Some(last) = &h.last_used_at {
println!("last_used_at: {}", last);
}
println!("created: {}", h.created_at);
println!();
println!("─ 만든 이유 ────────────────────────────");
for line in h.reason.lines() {
println!("{}", line);
}
println!();
println!("─ 파일 ──────────────────────────────────");
for f in &h.file_paths {
println!(" {}", f);
}
println!();
println!("─ 진화 로그 ────────────────────────────");
for entry in &h.evolution_log {
let advice = entry
.source_advice_id
.as_deref()
.map(|a| format!(" (advice: {})", a))
.unwrap_or_default();
println!(
" {} [{}] {}{}",
entry.at, entry.kind, entry.summary, advice
);
}
Ok(())
}
fn cmd_signals_summary(project: Option<String>) -> Result<()> {
use rusqlite::params;
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let (sql, has_project) = if project.is_some() {
(
r#"SELECT event_type, COUNT(*)
FROM events
WHERE user_id = ?1 AND project = ?2
AND event_type LIKE 'signal.%'
GROUP BY event_type"#,
true,
)
} else {
(
r#"SELECT event_type, COUNT(*)
FROM events
WHERE user_id = ?1
AND event_type LIKE 'signal.%'
GROUP BY event_type"#,
false,
)
};
let mut stmt = conn.prepare(sql)?;
let rows: Vec<(String, i64)> = if has_project {
stmt.query_map(params![&cfg.user.id, project.as_deref().unwrap()], |r| {
Ok((r.get(0)?, r.get(1)?))
})?
.filter_map(|r| r.ok())
.collect()
} else {
stmt.query_map(params![&cfg.user.id], |r| Ok((r.get(0)?, r.get(1)?)))?
.filter_map(|r| r.ok())
.collect()
};
if rows.is_empty() {
println!("(누적된 신호 없음)");
return Ok(());
}
for (kind, count) in rows {
println!("{:28} {}", kind, count);
}
Ok(())
}
fn mask(s: &str) -> String {
if s.len() <= 8 {
return "***".into();
}
format!("{}…{}", &s[..4], &s[s.len() - 4..])
}
async fn cmd_config_show() -> Result<()> {
let cfg = config::Config::load(&paths::config_file()?)?;
println!("[user]");
println!(" id = {}", cfg.user.id);
println!();
println!("[database]");
let url_display = if let Some(at) = cfg.database.url.find('@') {
let (creds, host) = cfg.database.url.split_at(at);
if let Some(colon) = creds.rfind(':') {
format!("{}:***{}", &creds[..colon], host)
} else {
cfg.database.url.clone()
}
} else {
cfg.database.url.clone()
};
println!(" url = {}", url_display);
println!();
println!("[server]");
println!(" port = {}", cfg.server.port);
println!(" bind = {}", cfg.server.bind);
println!();
println!("[tts]");
println!(" enabled = {}", cfg.tts.enabled);
println!(
" voice_id = {}",
cfg.tts.voice_id.as_deref().unwrap_or("(not set)")
);
println!(
" api_key = {}",
cfg.tts
.api_key
.as_deref()
.map(mask)
.unwrap_or_else(|| "(not set)".into())
);
Ok(())
}
fn cmd_config_path() -> Result<()> {
println!("{}", paths::config_file()?.display());
Ok(())
}
async fn cmd_config_set(key: String, value: String) -> Result<()> {
let path = paths::config_file()?;
let mut cfg = config::Config::load(&path)?;
match key.as_str() {
"database.url" => {
if !value.starts_with("postgres://") && !value.starts_with("postgresql://") {
return Err(anyhow::anyhow!(
"URL 은 'postgresql://' 또는 'postgres://' 로 시작해야 합니다."
));
}
cfg.database.url = value;
}
"server.port" => {
cfg.server.port = value
.parse()
.map_err(|_| anyhow::anyhow!("must be a number (1-65535)"))?
}
"server.bind" => cfg.server.bind = value,
"tts.enabled" => {
cfg.tts.enabled = value
.parse()
.map_err(|_| anyhow::anyhow!("must be true/false"))?
}
"tts.voice_id" => cfg.tts.voice_id = if value.is_empty() { None } else { Some(value) },
"tts.api_key" => cfg.tts.api_key = if value.is_empty() { None } else { Some(value) },
"user.id" => {
return Err(anyhow::anyhow!(
"user.id 는 직접 설정 불가.\n\
신규 발급은 config.toml 삭제 후 `asurada init` (기존 데이터 격리됨)"
));
}
_ => {
return Err(anyhow::anyhow!(
"Unknown key: {}\n\n\
사용 가능한 키:\n\
\tdatabase.url Postgres connection string\n\
\tserver.port HTTP API 포트 (기본 7878)\n\
\tserver.bind bind 주소 (기본 127.0.0.1)\n\
\ttts.enabled true | false\n\
\ttts.voice_id ElevenLabs voice id\n\
\ttts.api_key ElevenLabs API key\n\n\
read-only:\n\
\tuser.id UUID (init 시 자동 발급, 변경 불가)",
key
));
}
}
cfg.save(&path)?;
println!("✓ {} 설정됨", key);
if key.starts_with("database.") || key.starts_with("server.") {
println!(" 데몬 재시작 필요: launchctl kickstart -k gui/$(id -u)/dev.webchemist.asurada");
}
Ok(())
}
fn elevenlabs_key() -> Result<String> {
if let Ok(k) = std::env::var("ASURADA_ELEVENLABS_API_KEY") {
if !k.is_empty() {
return Ok(k);
}
}
let cfg = load_config()?;
cfg.tts.api_key.clone().ok_or_else(|| {
anyhow::anyhow!(
"ElevenLabs API 키 미설정.\n\
ASURADA_ELEVENLABS_API_KEY env 또는 config.toml [tts] api_key 에 설정.\n\
https://elevenlabs.io 에서 발급."
)
})
}
fn load_config() -> Result<config::Config> {
config::Config::load(&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" });
let key_set = cfg.tts.api_key.is_some()
|| std::env::var("ASURADA_ELEVENLABS_API_KEY")
.map(|v| !v.is_empty())
.unwrap_or(false);
println!("API key: {}", if key_set { "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 cfg = config::Config::load(&paths::config_file()?)?;
let creds = credentials::Credentials {
database_url: std::env::var("ASURADA_DATABASE_URL")
.unwrap_or_else(|_| cfg.database.url.clone()),
user_id: std::env::var("ASURADA_USER_ID").unwrap_or_else(|_| cfg.user.id.clone()),
};
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={} intents={} patterns={} issues={}",
p.events, p.memories, p.advice, p.projects, p.intents, p.patterns, p.issues
);
Ok(())
}
async fn cmd_sync_pull() -> Result<()> {
let s = open_sync().await?;
let p = s.pull_all().await?;
println!(
"pull events={} memories={} advice={} projects={} intents={} patterns={} issues={}",
p.events, p.memories, p.advice, p.projects, p.intents, p.patterns, p.issues
);
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={} intents={} patterns={} issues={}",
pu.events, pu.memories, pu.advice, pu.projects, pu.intents, pu.patterns, pu.issues
);
println!(
"pull events={} memories={} advice={} projects={} intents={} patterns={} issues={}",
pl.events, pl.memories, pl.advice, pl.projects, pl.intents, pl.patterns, pl.issues
);
Ok(())
}
async fn cmd_init() -> Result<()> {
let cfg_path = paths::config_file()?;
let brain_path = paths::brain_db()?;
if let Some(parent) = cfg_path.parent() {
std::fs::create_dir_all(parent)?;
}
let existing = config::Config::load(&cfg_path).ok();
let mut cfg = if let Some(prev) = existing {
println!("─────────────────────────────────────────────────────");
println!(" 기존 Asurada 발견");
println!("─────────────────────────────────────────────────────");
println!(" user.id = {}", prev.user.id);
println!(" config = {}", cfg_path.display());
println!();
println!("기존 설정을 보존합니다. 변경하려면:");
println!(" asurada config set <key> <value>");
println!();
prev
} else {
println!("═════════════════════════════════════════════════════");
println!(" 아스라다 첫 설치");
println!("═════════════════════════════════════════════════════");
println!();
println!("Asurada 는 사용자 본인의 Postgres 에 데이터를 저장합니다 (BYOK).");
println!("자격증명은 binary 에 박히지 않으며, ~/.asurada/config.toml (chmod 600)");
println!("에 평문 저장됩니다 — 사용자 본인만 읽기 가능.");
println!();
println!("─────────────────────────────────────────────────────");
println!(" Step 1. Postgres 준비");
println!("─────────────────────────────────────────────────────");
println!();
println!("[옵션 A] Supabase (추천 — 무료 500MB)");
println!(" 1. https://supabase.com 가입 / 로그인");
println!(" 2. New project 생성 (region: 가까운 곳)");
println!(" 3. 좌측 메뉴 → Project Settings → Database");
println!(" 4. Connection String → URI 탭 → 복사");
println!(" 예: postgresql://postgres:[YOUR-PASSWORD]@[host]:5432/postgres");
println!(" [YOUR-PASSWORD] 부분은 본인이 설정한 DB 비밀번호로 교체");
println!();
println!("[옵션 B] 자체 Postgres (Neon / RDS / 로컬 등)");
println!(" 본인 인스턴스의 connection string 준비");
println!();
println!("─────────────────────────────────────────────────────");
println!(" Step 2. URL 입력");
println!("─────────────────────────────────────────────────────");
println!();
let url = prompt_or_env("Postgres URL: ", "ASURADA_DATABASE_URL")?;
if url.is_empty() {
anyhow::bail!(
"database.url 은 필수입니다. https://supabase.com 에서 프로젝트 생성 후 다시 실행하세요."
);
}
if !url.starts_with("postgres://") && !url.starts_with("postgresql://") {
anyhow::bail!(
"URL 형식 오류. 'postgresql://' 또는 'postgres://' 로 시작해야 합니다.\n입력값: {}",
url
);
}
config::Config::fresh(url)
};
if let Ok(url) = std::env::var("ASURADA_DATABASE_URL") {
if !url.is_empty() && url != cfg.database.url {
cfg.database.url = url;
}
}
if cfg.tts.api_key.is_none() {
if let Ok(k) = std::env::var("ASURADA_ELEVENLABS_API_KEY") {
if !k.is_empty() {
cfg.tts.api_key = Some(k);
}
}
}
if cfg.tts.voice_id.is_none() {
if let Ok(v) = std::env::var("ASURADA_ELEVENLABS_VOICE_ID") {
if !v.is_empty() {
cfg.tts.voice_id = Some(v);
}
}
}
cfg.save(&cfg_path)?;
let _conn = db::open(&brain_path)?;
println!();
println!("═════════════════════════════════════════════════════");
println!(" ✓ 아스라다가 깨어났습니다 🌟");
println!("═════════════════════════════════════════════════════");
println!();
println!(" user.id: {}", cfg.user.id);
println!(" config: {}", cfg_path.display());
println!(" brain.db: {}", brain_path.display());
println!(
" tts: {}",
if cfg.tts.api_key.is_some() {
"✓ configured"
} else {
"(not set — 아래 가이드 참고)"
}
);
println!();
println!("─────────────────────────────────────────────────────");
println!(" 다음 단계");
println!("─────────────────────────────────────────────────────");
println!();
println!("1. Postgres 측 스키마 마이그레이션 (1회):");
println!(" psql \"$(asurada config show | awk '/url =/{{print $3}}')\" \\");
println!(" -f /usr/local/share/asurada/migrations/0001_init.sql");
println!(" 또는 Supabase Studio SQL 에디터에서 실행");
println!();
println!("2. 데몬 등록 (launchd, 자동 시작):");
println!(" asurada-install-launchd # Homebrew 로 설치 시");
println!(" 또는 직접:");
println!(" ./scripts/install-launchd.sh");
println!();
println!("3. (선택) TTS 활성화:");
println!(" https://elevenlabs.io 에서 API 키 발급 후");
println!(" asurada config set tts.api_key 'sk_...'");
println!(" asurada tts voices # voice 선택");
println!(" asurada config set tts.voice_id '...'");
println!(" asurada tts on");
println!();
println!("4. 상태 확인:");
println!(" asurada config show # 전체 설정");
println!(" asurada tts status # TTS 상태");
println!();
Ok(())
}
async fn cmd_brief(project: Option<String>, days: i64) -> Result<()> {
use std::sync::{Arc, Mutex};
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let brain = Arc::new(Mutex::new(conn));
let projects: Vec<String> = match project {
Some(p) => vec![p],
None => {
let conn = brain.lock().unwrap();
db::project::list(&conn, &cfg.user.id)?
.into_iter()
.map(|p| p.name)
.collect()
}
};
if projects.is_empty() {
println!("(등록된 프로젝트 없음)");
return Ok(());
}
for p in projects {
let has_activity = {
let conn = brain.lock().unwrap();
synthesis::should_brief(&conn, &cfg.user.id, &p).unwrap_or(false)
};
if !has_activity {
println!("[{}] 최근 활동 없음 또는 12h 내 brief 있음 → skip", p);
continue;
}
println!("[{}] brief 생성 중... (claude -p, 최대 2분)", p);
let result =
synthesis::brief_project(brain.clone(), cfg.user.id.clone(), p.clone(), days).await;
match result {
Ok(Some(m)) => {
println!("✓ memory 저장: {}", m.id);
println!();
for line in m.text.lines() {
println!(" {}", line);
}
println!();
}
Ok(None) => println!("[{}] 응답 없음 (skip)", p),
Err(e) => println!("[{}] 실패: {}", p, e),
}
}
Ok(())
}
async fn cmd_issue_capture(hours: i64) -> Result<()> {
use std::sync::{Arc, Mutex};
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let brain = Arc::new(Mutex::new(conn));
println!("issue capture 중... (claude -p, 최대 2분)");
match synthesis::capture_issue(brain.clone(), cfg.user.id.clone(), hours).await {
Ok(Some(issue)) => {
println!("✓ issue 저장: {}", issue.id);
println!();
println!(" 제목: {}", issue.title);
println!(" 프로젝트들: {}", issue.projects.join(", "));
println!(" events: {}", issue.event_count);
println!(" 시작: {}", issue.started_at);
println!(
" 끝: {}",
issue.ended_at.as_deref().unwrap_or("-")
);
println!();
println!("─ 요약 ────────────────────────");
for line in issue.summary.lines() {
println!("{}", line);
}
}
Ok(None) => println!("(최근 활동 없음 — 캡처할 거리 없음)"),
Err(e) => println!("실패: {}", e),
}
Ok(())
}
fn cmd_issue_list(limit: usize) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let issues = db::issue::list(&conn, &cfg.user.id, limit)?;
if issues.is_empty() {
println!("(issue 없음 — `asurada issue capture` 로 첫 issue 만들기)");
return Ok(());
}
println!("{:8} {:16} {:20} title", "id", "started", "projects");
for it in issues {
let id_short = &it.id[..8.min(it.id.len())];
let started: String = it.started_at.chars().take(16).collect();
let projects: String = it.projects.join(", ").chars().take(20).collect();
let title: String = it.title.chars().take(50).collect();
println!("{:8} {:16} {:20} {}", id_short, started, projects, title);
}
Ok(())
}
fn cmd_issue_get(id: String) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let all = db::issue::list(&conn, &cfg.user.id, 1000)?;
let it = all
.into_iter()
.find(|i| i.id.starts_with(&id))
.ok_or_else(|| anyhow::anyhow!("issue 없음: {}", id))?;
println!("id: {}", it.id);
println!("title: {}", it.title);
println!("projects: {}", it.projects.join(", "));
println!("status: {}", it.status);
println!("event_count: {}", it.event_count);
println!("started: {}", it.started_at);
println!("ended: {}", it.ended_at.as_deref().unwrap_or("-"));
println!();
println!("─ summary ────────────────────");
for line in it.summary.lines() {
println!("{}", line);
}
Ok(())
}
fn cmd_memory_list(scope: Option<String>, limit: usize) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let memories = db::memory::list(&conn, &cfg.user.id, scope.as_deref(), limit)?;
if memories.is_empty() {
println!("(메모리 없음)");
return Ok(());
}
println!(
"{:8} {:8} {:11} {:14} text",
"id", "scope", "priority", "project"
);
for m in memories {
let id_short = &m.id[..8.min(m.id.len())];
let project: String = m.project.unwrap_or_default().chars().take(14).collect();
let text: String = m.text.replace('\n', " ").chars().take(60).collect();
println!(
"{:8} {:8} {:11} {:14} {}",
id_short, m.scope, m.priority, project, text
);
}
Ok(())
}
fn cmd_memory_get(id: String) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let all = db::memory::list(&conn, &cfg.user.id, None, 5000)?;
let m = all
.into_iter()
.find(|x| x.id.starts_with(&id))
.ok_or_else(|| anyhow::anyhow!("메모리 없음: {}", id))?;
println!("id: {}", m.id);
println!("scope: {}", m.scope);
println!("priority: {}", m.priority);
println!("source: {}", m.source);
println!("project: {}", m.project.as_deref().unwrap_or("-"));
println!("tech: {}", m.tech.as_deref().unwrap_or("-"));
println!("created: {}", m.created_at);
println!("updated: {}", m.updated_at);
println!();
println!("─ text ────────────────────");
for line in m.text.lines() {
println!("{}", line);
}
Ok(())
}
fn cmd_memory_search(query: String, limit: usize) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let memories = db::memory::search(&conn, &cfg.user.id, &query, limit)?;
if memories.is_empty() {
println!("(검색 결과 없음)");
return Ok(());
}
println!(
"{:8} {:8} {:11} {:14} text",
"id", "scope", "priority", "project"
);
for m in memories {
let id_short = &m.id[..8.min(m.id.len())];
let project: String = m.project.unwrap_or_default().chars().take(14).collect();
let text: String = m.text.replace('\n', " ").chars().take(60).collect();
println!(
"{:8} {:8} {:11} {:14} {}",
id_short, m.scope, m.priority, project, text
);
}
Ok(())
}
async fn cmd_reflect(days: i64) -> Result<()> {
use std::sync::{Arc, Mutex};
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let brain = Arc::new(Mutex::new(conn));
let label = if days <= 1 {
"오늘 흐름"
} else {
"이번 주 패턴"
};
println!("[{}] 통합 회고 생성 중... (claude -p, 최대 2분)", label);
match synthesis::reflect_recent(brain.clone(), cfg.user.id.clone(), days).await {
Ok(Some(m)) => {
println!("✓ memory 저장: {}", m.id);
println!();
for line in m.text.lines() {
println!(" {}", line);
}
}
Ok(None) => println!("(활동 없음 — 회고할 거리 없음)"),
Err(e) => println!("실패: {}", e),
}
Ok(())
}
async fn cmd_voice_greet(dry_run: bool) -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let preview = voice::preview_greeting(&conn, &cfg.user.id)?;
match &preview {
None => {
println!("(할 말 없음 — 모든 프로젝트 활동 중 + 보류 advice 0)");
if !dry_run {
let _ = db::event::insert(
&conn,
db::event::EventInput {
user_id: cfg.user.id.clone(),
project: "_".into(),
event_type: "voice.greeting".into(),
path: None,
payload: serde_json::json!({"text": null, "skipped": "all_clear"}),
},
);
}
}
Some(text) => {
println!("─ 인사 텍스트 ───────────────");
println!("{}", text);
println!();
if dry_run {
println!("(dry-run — 발화 안 함)");
return Ok(());
}
if !cfg.tts.enabled {
println!("(TTS off — 발화 안 함. `asurada tts on` 으로 활성화)");
return Ok(());
}
let key = elevenlabs_key()?;
let voice_id = cfg
.tts
.voice_id
.clone()
.ok_or_else(|| anyhow::anyhow!("voice_id 미설정"))?;
let client = ai::elevenlabs::ElevenLabsClient::new(key)?;
client.speak(text, &voice_id).await?;
voice::mark_greeted(&conn, &cfg.user.id, text)?;
println!("✓ 발화됨");
}
}
Ok(())
}
fn cmd_voice_survey() -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let insights = voice::survey_projects(&conn, &cfg.user.id)?;
if insights.is_empty() {
println!("(등록된 프로젝트 없음)");
return Ok(());
}
println!(
"{:20} {:>8} {:>8} last prompt",
"project", "idle d", "advice"
);
for i in insights {
let preview = i.last_prompt_preview.as_deref().unwrap_or("(활동 없음)");
let idle_label = if i.idle_days < 0 {
"—".to_string()
} else {
format!("{}d", i.idle_days)
};
println!(
"{:20} {:>8} {:>8} {}",
i.project, idle_label, i.pending_advice, preview
);
}
Ok(())
}
fn cmd_voice_status() -> Result<()> {
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let last = voice::last_greeting_time(&conn, &cfg.user.id)?;
let already = voice::already_greeted_today(&conn, &cfg.user.id)?;
println!(
"TTS: {}",
if cfg.tts.enabled { "ON" } else { "OFF" }
);
println!("daily_greeting: {}", cfg.tts.daily_greeting);
println!("auto_speak_advice: {}", cfg.tts.auto_speak_advice);
println!(
"quiet_hours: {}",
cfg.tts.quiet_hours.as_deref().unwrap_or("(없음)")
);
println!(
"in_quiet_hours_now: {}",
voice::in_quiet_hours(cfg.tts.quiet_hours.as_deref())
);
println!(
"last_greeting: {}",
last.as_deref().unwrap_or("(없음)")
);
println!("greeted_today: {}", already);
Ok(())
}
fn cmd_admin_normalize_events(apply: bool) -> Result<()> {
use rusqlite::params;
let cfg = load_config()?;
let conn = db::open(&paths::brain_db()?)?;
let mut stmt = conn.prepare("SELECT name FROM projects WHERE user_id = ?1")?;
let canonicals: Vec<String> = stmt
.query_map(params![&cfg.user.id], |r| r.get(0))?
.filter_map(|r| r.ok())
.collect();
if canonicals.is_empty() {
println!("(projects 테이블 비어있음 — 정규화 불가)");
return Ok(());
}
let mut total_affected = 0i64;
let mut summary: Vec<(String, String, i64)> = Vec::new();
for canonical in &canonicals {
let mut stmt = conn.prepare(
r#"SELECT DISTINCT project FROM events
WHERE user_id = ?1
AND LOWER(project) = LOWER(?2)
AND project != ?2"#,
)?;
let variants: Vec<String> = stmt
.query_map(params![&cfg.user.id, canonical], |r| r.get(0))?
.filter_map(|r| r.ok())
.collect();
for v in variants {
let mut count_stmt =
conn.prepare("SELECT COUNT(*) FROM events WHERE user_id = ?1 AND project = ?2")?;
let n: i64 = count_stmt
.query_row(params![&cfg.user.id, &v], |r| r.get(0))
.unwrap_or(0);
if n == 0 {
continue;
}
summary.push((v.clone(), canonical.clone(), n));
total_affected += n;
if apply {
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
r#"UPDATE events
SET project = ?1, updated_at = ?2, synced_at = NULL
WHERE user_id = ?3 AND project = ?4"#,
params![canonical, now, &cfg.user.id, &v],
)?;
}
}
}
if summary.is_empty() {
println!("(정규화 대상 없음 — 모든 events 가 canonical 사용 중)");
return Ok(());
}
println!("─ 정규화 대상 ─────────────────────────");
for (from, to, n) in &summary {
println!(" {:20} → {:20} ({}건)", from, to, n);
}
println!();
if apply {
println!(
"✓ 적용 완료. 총 {}건 갱신, sync 다음 cycle 에 push.",
total_affected
);
} else {
println!(
"(dry-run — 총 {}건 영향. 실제 적용은 --apply)",
total_affected
);
}
Ok(())
}
fn cmd_onboard() -> Result<()> {
use std::io::{self, Write};
let cfg = config::Config::load(&paths::config_file()?).map_err(|_| {
anyhow::anyhow!("config 없음 — 먼저 `asurada init` 으로 user.id + database.url 설정 필요")
})?;
let conn = db::open(&paths::brain_db()?)?;
let existing = db::intent::list_all(&conn, &cfg.user.id)?;
if !existing.is_empty() {
println!("이미 intent 가 있습니다. onboard 는 빈 상태에서만 권장됩니다.");
println!("기존 intent: {}", existing.len());
print!("그래도 진행할까요? [y/N]: ");
io::stdout().flush().ok();
let mut line = String::new();
io::stdin().read_line(&mut line).ok();
if !line.trim().eq_ignore_ascii_case("y") {
println!("취소.");
return Ok(());
}
}
println!();
println!("═════════════════════════════════════════════════════");
println!(" Asurada 와 함께 시작 — 5문항");
println!("═════════════════════════════════════════════════════");
println!();
println!("이 답변들은 초기 intent 로 저장됩니다.");
println!("나중에 `asurada intent add/archive` 로 추가/변경 가능.");
println!();
let mut created: Vec<(String, String)> = Vec::new();
println!("[1/5] 응답 언어 (Asurada/Claude 가 사용자에게 답할 때 기본 언어)");
println!(" 1) 한국어 2) English 3) 둘 다 (혼용)");
let lang = prompt_choice("> ", &["1", "2", "3"], "1")?;
let lang_text = match lang.as_str() {
"1" => "응답은 한국어로 작성하라. 코드 식별자/명령어는 원형 유지.",
"2" => "Reply in English by default. Keep code identifiers in their original form.",
_ => "응답 언어는 사용자가 마지막으로 사용한 언어를 따른다. 일관성 우선.",
};
add_intent(
&conn,
&cfg.user.id,
db::intent::Strength::Preference,
lang_text,
None,
)?;
created.push(("preference".into(), lang_text.into()));
println!();
println!("[2/5] 코드 작성 시 *강한* 선호 (한 줄, 비워두면 skip)");
println!(" 예: \"새 기능 추가 시 테스트도 같이\", \"커밋 메시지는 conventional\" 등");
let pref = prompt_line("> ")?;
if !pref.is_empty() {
add_intent(
&conn,
&cfg.user.id,
db::intent::Strength::Preference,
&pref,
None,
)?;
created.push(("preference".into(), pref));
}
println!();
println!("[3/5] destructive git 작업 (push --force, reset --hard) 전 자동 확인?");
println!(" Y) 예 (Asurada 가 hook 으로 차단/확인)");
println!(" n) 아니오");
let git_protect = prompt_choice("> ", &["y", "Y", "n", "N", ""], "y")?;
if !git_protect.eq_ignore_ascii_case("n") {
for (label, contains) in &[
("git push --force", "git push --force"),
("git reset --hard", "git reset --hard"),
] {
let mut metadata = serde_json::Map::new();
let mut trigger = serde_json::Map::new();
trigger.insert("tool".into(), serde_json::Value::String("Bash".into()));
trigger.insert(
"contains".into(),
serde_json::Value::String((*contains).into()),
);
metadata.insert("trigger".into(), serde_json::Value::Object(trigger));
metadata.insert("decision".into(), serde_json::Value::String("ask".into()));
let text = format!("{} 전 사용자 확인 필수", label);
db::intent::insert(
&conn,
db::intent::IntentInput {
user_id: cfg.user.id.clone(),
project: None,
strength: db::intent::Strength::Principle,
intent_text: text.clone(),
source: db::intent::Source::User,
source_signal_ids: vec![],
metadata: serde_json::Value::Object(metadata),
},
)?;
created.push(("principle".into(), text));
}
}
println!();
println!("[4/5] 상시 기억해야 할 맥락 (한 줄, 비워두면 skip)");
println!(" 예: \"주력 스택은 Rust + React\", \"팀 합의: PR 라벨 필수\" 등");
let ctx = prompt_line("> ")?;
if !ctx.is_empty() {
add_intent(
&conn,
&cfg.user.id,
db::intent::Strength::Context,
&ctx,
None,
)?;
created.push(("context".into(), ctx));
}
println!();
println!("[5/5] AI 와의 협업 모드");
println!(" 1) 함께 결정 — 큰 결정은 물어보고 진행");
println!(" 2) 자율 진행 — 작은 결정은 알아서, 큰 것만 보고");
println!(" 3) 일일이 확인 — 모든 단계 사용자 승인");
let mode = prompt_choice("> ", &["1", "2", "3"], "1")?;
let mode_text = match mode.as_str() {
"1" => "AI 는 큰 결정 (아키텍처/배포/destructive 작업) 전 사용자에게 묻고, 작은 실행은 보고만 한다.",
"2" => "AI 는 결정 권한을 갖되, 사용자 명시 의도와 어긋나면 즉시 중단해 묻는다. 보고는 결과 위주로 간결.",
_ => "AI 는 모든 단계 (도구 호출/파일 변경/커밋) 전 사용자 승인을 받는다. 자율 실행 금지.",
};
add_intent(
&conn,
&cfg.user.id,
db::intent::Strength::Preference,
mode_text,
None,
)?;
created.push(("preference".into(), mode_text.into()));
println!();
println!("─────────────────────────────────────────────");
println!(" ✓ 저장됨");
println!("─────────────────────────────────────────────");
println!();
for (kind, text) in &created {
let trimmed: String = text.chars().take(70).collect();
let suffix = if text.chars().count() > 70 { "…" } else { "" };
println!(" [{}] {}{}", kind, trimmed, suffix);
}
println!();
println!("다음 단계:");
println!(" asurada intent compile Claude Code 에 적용");
println!(" asurada hook install 사용자 프롬프트 관찰 시작");
println!(" asurada serve 패턴 자동 생성을 위한 데몬 가동");
Ok(())
}
fn add_intent(
conn: &rusqlite::Connection,
user_id: &str,
strength: db::intent::Strength,
text: &str,
project: Option<String>,
) -> Result<()> {
db::intent::insert(
conn,
db::intent::IntentInput {
user_id: user_id.into(),
project,
strength,
intent_text: text.into(),
source: db::intent::Source::User,
source_signal_ids: vec![],
metadata: serde_json::json!({}),
},
)?;
Ok(())
}
fn prompt_line(prompt: &str) -> Result<String> {
use std::io::{self, Write};
print!("{}", prompt);
io::stdout().flush().ok();
let mut line = String::new();
io::stdin().read_line(&mut line)?;
Ok(line.trim().to_string())
}
fn prompt_choice(prompt: &str, allowed: &[&str], default: &str) -> Result<String> {
use std::io::{self, Write};
print!("{}", prompt);
io::stdout().flush().ok();
let mut line = String::new();
io::stdin().read_line(&mut line)?;
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(default.into());
}
if allowed.iter().any(|a| a == &trimmed) {
return Ok(trimmed.to_string());
}
Ok(default.into())
}
fn prompt_or_env(prompt: &str, env_var: &str) -> Result<String> {
if let Ok(v) = std::env::var(env_var) {
if !v.is_empty() {
return Ok(v);
}
}
use std::io::{self, Write};
print!("{}", prompt);
io::stdout().flush().ok();
let mut line = String::new();
io::stdin().read_line(&mut line)?;
Ok(line.trim().to_string())
}
async fn cmd_serve(
port: u16,
auto_pattern_interval: u64,
auto_evolution_threshold: usize,
auto_redo_threshold: usize,
) -> Result<()> {
use std::sync::{Arc, Mutex};
use std::time::Duration;
let cfg = config::Config::load(&paths::config_file()?)?;
let brain_path = paths::brain_db()?;
let conn = db::open(&brain_path)?;
let brain = Arc::new(Mutex::new(conn));
let creds = credentials::Credentials {
database_url: cfg.database.url.clone(),
user_id: cfg.user.id.clone(),
};
match sync::Sync::connect(brain.clone(), &creds).await {
Ok(s) => {
tokio::spawn(s.run_loop(Duration::from_secs(30)));
}
Err(e) => {
tracing::warn!("[sync] connect failed: {}", e);
}
}
let voice_queue = voice::VoiceQueue::spawn(&cfg);
if voice_queue.is_some() {
tracing::info!("[voice] queue active");
}
if let Some(vq) = voice_queue.clone() {
if cfg.tts.daily_greeting {
let brain_for_voice = brain.clone();
let user_id = cfg.user.id.clone();
let quiet_hours = cfg.tts.quiet_hours.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await;
let normal_interval = Duration::from_secs(60); let immediate_after_wake = Duration::from_secs(2); let mut last_wall = chrono::Local::now();
let mut just_woke = false;
loop {
let now = chrono::Local::now();
let gap = now.signed_duration_since(last_wall);
let woke_now = gap.num_minutes() >= 5;
if woke_now {
tracing::info!(
"[voice] wall-clock jump {}분 — Mac wake 감지, 즉시 인사 검사",
gap.num_minutes()
);
just_woke = true;
}
last_wall = now;
let brain = brain_for_voice.clone();
let uid = user_id.clone();
let q = vq.clone();
let qh = quiet_hours.clone();
let _ = tokio::task::spawn_blocking(move || {
let conn = brain.lock().expect("brain mutex poisoned");
if let Err(e) = voice::try_daily_greeting(&conn, &uid, &q, qh.as_deref()) {
tracing::warn!("[voice] greeting failed: {}", e);
}
})
.await;
let next = if just_woke {
just_woke = false;
immediate_after_wake
} else {
normal_interval
};
tokio::time::sleep(next).await;
}
});
}
}
if let Some(vq) = voice_queue.clone() {
if cfg.tts.auto_speak_advice {
let brain_for_voice = brain.clone();
let user_id = cfg.user.id.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(10)).await;
let interval = Duration::from_secs(30);
loop {
let brain = brain_for_voice.clone();
let uid = user_id.clone();
let q = vq.clone();
let _ = tokio::task::spawn_blocking(move || {
let conn = brain.lock().expect("brain mutex poisoned");
if let Err(e) = voice::try_speak_new_advice(&conn, &uid, &q, 5) {
tracing::warn!("[voice] advice speak failed: {}", e);
}
})
.await;
tokio::time::sleep(interval).await;
}
});
}
}
{
let brain_for_reflect = brain.clone();
let user_id = cfg.user.id.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(120)).await; let interval = Duration::from_secs(60 * 60); loop {
let (do_daily, do_weekly) = {
let conn = brain_for_reflect.lock().expect("brain mutex poisoned");
(
synthesis::should_reflect_daily(&conn, &user_id).unwrap_or(false),
synthesis::should_reflect_weekly(&conn, &user_id).unwrap_or(false),
)
};
if do_daily {
let r =
synthesis::reflect_recent(brain_for_reflect.clone(), user_id.clone(), 1)
.await;
match r {
Ok(Some(m)) => {
tracing::info!("[reflect daily] memory {}", &m.id[..8.min(m.id.len())])
}
Ok(None) => tracing::debug!("[reflect daily] no activity"),
Err(e) => tracing::warn!("[reflect daily] failed: {}", e),
}
}
if do_weekly {
let r =
synthesis::reflect_recent(brain_for_reflect.clone(), user_id.clone(), 7)
.await;
match r {
Ok(Some(m)) => {
tracing::info!("[reflect weekly] memory {}", &m.id[..8.min(m.id.len())])
}
Ok(None) => tracing::debug!("[reflect weekly] no activity"),
Err(e) => tracing::warn!("[reflect weekly] failed: {}", e),
}
}
tokio::time::sleep(interval).await;
}
});
tracing::info!("[reflect auto] enabled (daily 24h / weekly 7d)");
}
{
let brain_for_brief = brain.clone();
let user_id_for_brief = cfg.user.id.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(60)).await;
let interval = Duration::from_secs(12 * 60 * 60); loop {
let projects: Vec<String> = {
let conn = brain_for_brief.lock().expect("brain mutex poisoned");
db::project::list(&conn, &user_id_for_brief)
.unwrap_or_default()
.into_iter()
.filter(|p| {
synthesis::should_brief(&conn, &user_id_for_brief, &p.name)
.unwrap_or(false)
})
.map(|p| p.name)
.collect()
};
for project in projects {
let brain = brain_for_brief.clone();
let uid = user_id_for_brief.clone();
let proj = project.clone();
let result = synthesis::brief_project(brain, uid, proj, 1).await;
match result {
Ok(Some(m)) => tracing::info!(
"[brief auto] {} → memory {}",
project,
&m.id[..8.min(m.id.len())]
),
Ok(None) => tracing::debug!("[brief auto] {} no activity", project),
Err(e) => tracing::warn!("[brief auto] {} failed: {}", project, e),
}
}
tokio::time::sleep(interval).await;
}
});
tracing::info!("[brief auto] enabled (interval=12h)");
}
if auto_pattern_interval > 0 {
let brain_for_pattern = brain.clone();
let user_id = cfg.user.id.clone();
tokio::spawn(async move {
let interval = Duration::from_secs(auto_pattern_interval);
tokio::time::sleep(interval).await;
loop {
let brain = brain_for_pattern.clone();
let uid = user_id.clone();
let result =
tokio::task::spawn_blocking(move || -> anyhow::Result<(usize, usize)> {
let conn = brain.lock().expect("brain mutex poisoned");
let clusters =
detect::scan_redo_clusters(&conn, &uid, None, 30, auto_redo_threshold)?;
let added_redo = detect::propose_from_clusters(&conn, &uid, &clusters)?;
let candidates = detect::scan_pattern_evolution_candidates(
&conn,
&uid,
auto_evolution_threshold,
30,
)?;
let mut added_evol = 0usize;
for c in &candidates {
if detect::already_proposed_evolution(&conn, &uid, &c.pattern_id)? {
continue;
}
detect::propose_pattern_evolution(&conn, &uid, c)?;
added_evol += 1;
}
Ok((added_redo, added_evol))
})
.await;
match result {
Ok(Ok((r, e))) if r > 0 || e > 0 => {
tracing::info!("[pattern auto] redo+={} evolution+={}", r, e);
}
Ok(Ok(_)) => {
tracing::debug!("[pattern auto] no new proposals");
}
Ok(Err(e)) => {
tracing::warn!("[pattern auto] scan failed: {}", e);
}
Err(e) => {
tracing::warn!("[pattern auto] task panicked: {}", e);
}
}
tokio::time::sleep(interval).await;
}
});
tracing::info!(
"[pattern auto] enabled (interval={}s, redo>={}, evolution>={})",
auto_pattern_interval,
auto_redo_threshold,
auto_evolution_threshold
);
}
let state = api::AppState { conn: brain };
let app = api::router(state);
let addr = format!("127.0.0.1:{}", port);
tracing::info!("listening on http://{} (user.id={})", addr, cfg.user.id);
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(())
}