use anyhow::{anyhow, Result};
use clap::Subcommand;
use console::style;
use std::io::{self, Write};
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
use crate::paths;
use crate::worker::config::WorkerConfig;
use crate::worker::daemon;
use crate::worker::db::Db;
#[derive(Subcommand)]
pub enum WorkerCmd {
Start,
Stop,
Status,
Watch {
#[arg(long, default_value_t = 20)]
tail: usize,
#[arg(long, default_value_t = 500)]
interval_ms: u64,
},
#[command(subcommand)]
Config(ConfigCmd),
Advice {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value_t = 20)]
limit: usize,
},
Memory {
#[command(subcommand)]
cmd: MemoryCmd,
},
Rules {
#[command(subcommand)]
cmd: RulesCmd,
},
#[command(name = "__run", hide = true)]
RunInternal,
}
#[derive(Subcommand)]
pub enum MemoryCmd {
Search {
query: String,
#[arg(long, default_value_t = 5)]
limit: usize,
},
}
#[derive(Subcommand)]
pub enum RulesCmd {
Path,
Show,
Init,
}
#[derive(Subcommand)]
pub enum ConfigCmd {
Show,
Get { key: String },
Set { key: String, value: String },
Path,
}
pub fn run(cmd: WorkerCmd) -> Result<()> {
match cmd {
WorkerCmd::Start => start(),
WorkerCmd::Stop => stop(),
WorkerCmd::Status => status(),
WorkerCmd::Watch { tail, interval_ms } => watch(tail, interval_ms),
WorkerCmd::Config(c) => config_cmd(c),
WorkerCmd::Advice { project, limit } => advice_list(project, limit),
WorkerCmd::Memory { cmd } => memory_cmd(cmd),
WorkerCmd::Rules { cmd } => rules_cmd(cmd),
WorkerCmd::RunInternal => daemon::run_loop(),
}
}
fn rules_cmd(cmd: RulesCmd) -> Result<()> {
use crate::worker::rules::{ensure_global_template, Rules};
match cmd {
RulesCmd::Path => {
println!("{}", paths::worker_rules_file()?.display());
Ok(())
}
RulesCmd::Show => {
let rules = Rules::load_global();
println!("{}", rules.render());
Ok(())
}
RulesCmd::Init => {
let path = ensure_global_template()?;
println!(" {} {}", style("[RULES]").green(), path.display());
Ok(())
}
}
}
fn advice_list(project: Option<String>, limit: usize) -> Result<()> {
let cfg = WorkerConfig::load()?;
let db = Db::open(&cfg.db_path)?;
let recent = db.recent(limit * 5)?; let mut shown = 0;
println!("{}", style("devist worker advice").bold());
println!();
for ev in recent.iter().rev() {
if ev.event_type != "advice" && ev.event_type != "advice_error" {
continue;
}
if let Some(p) = &project {
if &ev.project != p {
continue;
}
}
if shown >= limit {
break;
}
let ts = ev.created_at.split('T').nth(1).unwrap_or(&ev.created_at);
let ts = ts.split('.').next().unwrap_or(ts);
let sev = match ev.severity.as_str() {
"warn" => style(format!("[{}]", ev.severity)).yellow().to_string(),
"block" => style(format!("[{}]", ev.severity)).red().to_string(),
"suggest" => style(format!("[{}]", ev.severity)).cyan().to_string(),
_ => style(format!("[{}]", ev.severity)).dim().to_string(),
};
let text = serde_json::from_str::<serde_json::Value>(&ev.payload)
.ok()
.and_then(|v| {
v.get("text")
.or_else(|| v.get("error"))
.and_then(|x| x.as_str().map(|s| s.to_string()))
})
.unwrap_or_else(|| ev.payload.clone());
println!(
"{} {} {} — {}",
style(ts).dim(),
sev,
style(&ev.project).cyan(),
text
);
shown += 1;
}
if shown == 0 {
println!(" {} no advice yet", style("(empty)").dim());
}
Ok(())
}
fn memory_cmd(cmd: MemoryCmd) -> Result<()> {
let cfg = WorkerConfig::load()?;
let api_key = cfg.mem0_api_key.clone().ok_or_else(|| {
anyhow!("mem0_api_key not set. `devist worker config set mem0_api_key <key>`")
})?;
let user_id = cfg
.mem0_user_id
.clone()
.ok_or_else(|| anyhow!("mem0_user_id not set"))?;
let client = crate::worker::mem0::Mem0Client::new(api_key, user_id)?;
match cmd {
MemoryCmd::Search { query, limit } => {
let results = client.search(&query, limit)?;
if results.is_empty() {
println!("{}", style("(no memories matched)").dim());
return Ok(());
}
for (i, m) in results.iter().enumerate() {
let score = m
.score
.map(|s| format!("{:.2}", s))
.unwrap_or_else(|| "?".into());
println!(
"{} {} {}",
style(format!("{}.", i + 1)).dim(),
style(format!("[score {}]", score)).cyan(),
m.memory
);
}
Ok(())
}
}
}
fn start() -> Result<()> {
println!("{}", style("devist worker start").bold());
let st = daemon::status()?;
if st.running {
println!(
" {} already running (pid {})",
style("[OK]").green(),
st.pid.unwrap()
);
return Ok(());
}
if st.stale_pid_file {
println!(
" {} stale PID file from previous run (pid {}) — cleaned up",
style("[CLEAN]").dim(),
st.pid.unwrap()
);
}
if !WorkerConfig::exists() {
let cfg = first_run_setup()?;
cfg.save()?;
println!(" {} config saved", style("[CFG]").cyan());
}
let cfg = WorkerConfig::load()?;
if !cfg.monitor_dir.exists() {
return Err(anyhow!(
"Monitor folder does not exist: {}\n Update with: devist worker config set monitor_dir <path>",
cfg.monitor_dir.display()
));
}
let pid = daemon::spawn_detached()?;
println!(" {} pid {}", style("[UP]").green(), pid);
println!(
" {} {}",
style("[WATCH]").cyan(),
style(cfg.monitor_dir.display()).bold()
);
println!(
" {} {}",
style("[LOG]").dim(),
paths::worker_log_file()?.display()
);
println!(" {} {}", style("[DB]").dim(), cfg.db_path.display());
Ok(())
}
fn stop() -> Result<()> {
println!("{}", style("devist worker stop").bold());
daemon::stop()?;
println!(" {} stopped", style("[DOWN]").yellow());
Ok(())
}
fn status() -> Result<()> {
println!("{}", style("devist worker status").bold());
let st = daemon::status()?;
if st.running {
println!(" {} running (pid {})", style("●").green(), st.pid.unwrap());
} else if st.stale_pid_file {
println!(
" {} stopped (stale PID file: {})",
style("○").yellow(),
st.pid.unwrap()
);
} else {
println!(" {} stopped", style("○").dim());
}
if let Ok(cfg) = WorkerConfig::load() {
println!(
" {} {}",
style("monitor:").dim(),
cfg.monitor_dir.display()
);
println!(" {} {}", style("db: ").dim(), cfg.db_path.display());
match cfg.supabase_url.as_deref() {
Some(u) => println!(" {} {}", style("supabase:").dim(), u),
None => println!(
" {} {}",
style("supabase:").dim(),
style("not configured").dim()
),
}
if let Ok(db) = Db::open(&cfg.db_path) {
if let Ok(c) = db.counts() {
println!(
" {} {} events ({} pending sync)",
style("data: ").dim(),
c.total,
c.unsynced
);
}
}
} else {
println!(
" {} {}",
style("config:").dim(),
style("not configured — run `devist worker start`").dim()
);
}
Ok(())
}
fn watch(tail: usize, interval_ms: u64) -> Result<()> {
let cfg = WorkerConfig::load()?;
let db = Db::open(&cfg.db_path)?;
println!(
"{} (db: {}) press Ctrl+C to stop",
style("devist worker watch").bold(),
style(cfg.db_path.display()).dim()
);
println!();
let initial = db.recent(tail)?;
let mut last_id = 0i64;
for ev in &initial {
print_event(ev);
if let Some(id) = ev.id {
last_id = id;
}
}
loop {
thread::sleep(Duration::from_millis(interval_ms));
let new_events = db.since(last_id, 200)?;
for ev in &new_events {
print_event(ev);
if let Some(id) = ev.id {
last_id = id;
}
}
}
}
fn print_event(ev: &crate::worker::db::Event) {
let ts = ev.created_at.split('T').nth(1).unwrap_or(&ev.created_at);
let ts = ts.split('.').next().unwrap_or(ts);
let sev_styled = match ev.severity.as_str() {
"warn" => style(format!("[{}]", ev.severity)).yellow().to_string(),
"block" => style(format!("[{}]", ev.severity)).red().to_string(),
"suggest" => style(format!("[{}]", ev.severity)).cyan().to_string(),
_ => style(format!("[{}]", ev.severity)).dim().to_string(),
};
println!(
"{} {} {} {} {}",
style(ts).dim(),
sev_styled,
style(&ev.event_type).bold(),
style(&ev.project).cyan(),
ev.path.as_deref().unwrap_or("")
);
}
fn config_cmd(cmd: ConfigCmd) -> Result<()> {
match cmd {
ConfigCmd::Show => {
let cfg = WorkerConfig::load()?;
println!("{}", toml::to_string_pretty(&cfg)?);
Ok(())
}
ConfigCmd::Get { key } => {
let cfg = WorkerConfig::load()?;
let v = match key.as_str() {
"monitor_dir" => cfg.monitor_dir.display().to_string(),
"db_path" => cfg.db_path.display().to_string(),
"supabase_url" => cfg.supabase_url.unwrap_or_default(),
"supabase_key" => cfg.supabase_key.unwrap_or_default(),
"sync_interval_secs" => cfg.sync_interval_secs.to_string(),
"debounce_ms" => cfg.debounce_ms.to_string(),
_ => return Err(anyhow!("Unknown key: {}", key)),
};
println!("{}", v);
Ok(())
}
ConfigCmd::Set { key, value } => {
let mut cfg = WorkerConfig::load()?;
cfg.set_key(&key, &value)?;
cfg.save()?;
println!(" {} {} = {}", style("[CFG]").green(), key, value);
let st = daemon::status()?;
if st.running {
println!(
" {} restart with `devist worker stop && devist worker start` to apply",
style("[NOTE]").yellow()
);
}
Ok(())
}
ConfigCmd::Path => {
println!("{}", paths::worker_config_file()?.display());
Ok(())
}
}
}
fn first_run_setup() -> Result<WorkerConfig> {
println!();
println!(" {} first-time setup", style("[SETUP]").cyan());
println!(" Enter the folder to monitor (a parent folder containing your projects).");
let default = paths::home()?.join("Workspace");
let prompt_default = if default.exists() {
default.display().to_string()
} else {
String::new()
};
print!(
" monitor_dir [{}]: ",
if prompt_default.is_empty() {
"required"
} else {
&prompt_default
}
);
io::stdout().flush().ok();
let mut line = String::new();
io::stdin().read_line(&mut line)?;
let entered = line.trim().to_string();
let monitor_dir = if entered.is_empty() {
if prompt_default.is_empty() {
return Err(anyhow!("monitor_dir is required"));
}
PathBuf::from(prompt_default)
} else {
PathBuf::from(entered)
};
if !monitor_dir.exists() {
return Err(anyhow!(
"Monitor folder does not exist: {}",
monitor_dir.display()
));
}
WorkerConfig::new_default(monitor_dir)
}