mod agent;
mod args;
mod banner;
pub(crate) mod config;
pub(crate) mod crypto;
pub(crate) mod error;
pub(crate) mod global_config;
pub(crate) mod llm;
pub(crate) mod protocol;
use std::io::Write as _;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use clap::Parser;
use console::style;
use dialoguer::{Confirm, Input, Password, Select};
use nostr_sdk::{Keys, ToBech32};
use solana_sdk::signature::Keypair;
use solana_sdk::signer::Signer;
use zeroize::Zeroizing;
use self::args::{Cli, Commands};
use self::config::{AgentConfig, LlmSection, PaymentSection};
use self::error::{CliError, Result};
use crate::util::{format_sol, format_sol_compact, sol_to_lamports};
fn init_tracing() {
use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
use tracing_subscriber::reload;
let base_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"))
.add_directive("nostr_relay_pool=off".parse().unwrap());
let (filter_layer, handle) = reload::Layer::new(base_filter);
let fmt_layer = tracing_subscriber::fmt::layer();
let subscriber = tracing_subscriber::registry()
.with(filter_layer)
.with(fmt_layer);
let _ = tracing::subscriber::set_global_default(subscriber);
TRACING_RELOAD_HANDLE.get_or_init(|| std::sync::Mutex::new(handle));
}
static TRACING_RELOAD_HANDLE: std::sync::OnceLock<
std::sync::Mutex<tracing_subscriber::reload::Handle<tracing_subscriber::EnvFilter, tracing_subscriber::Registry>>
> = std::sync::OnceLock::new();
fn silence_tracing() {
if let Some(handle) = TRACING_RELOAD_HANDLE.get() {
if let Ok(handle) = handle.lock() {
let _ = handle.reload(tracing_subscriber::EnvFilter::new("off"));
}
}
}
async fn with_spinner<F, T>(message: &str, fut: F) -> T
where
F: std::future::Future<Output = T>,
{
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop);
let msg = message.to_string();
let handle = tokio::spawn(async move {
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let mut i = 0;
loop {
if stop_clone.load(Ordering::Relaxed) {
print!("\r\x1b[2K {} {}\n", style("⣿").green(), style("Connected.").dim());
let _ = std::io::stdout().flush();
break;
}
print!("\r\x1b[2K {} {}", style(frames[i % frames.len()]).cyan(), style(&msg).dim());
let _ = std::io::stdout().flush();
i += 1;
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
}
});
let result = fut.await;
stop.store(true, Ordering::Relaxed);
let _ = handle.await;
result
}
fn fetch_models(provider: &str, api_key: &str) -> Vec<String> {
println!(" {} Fetching models...", style("~").dim());
let result = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(fetch_models_async(provider, api_key))
});
match result {
Ok(models) if !models.is_empty() => models,
Ok(_) => {
println!(" {} No models returned, using defaults.", style("!").yellow());
fallback_models(provider)
}
Err(e) => {
println!(" {} Could not fetch models: {}", style("!").yellow(), e);
fallback_models(provider)
}
}
}
fn fallback_models(provider: &str) -> Vec<String> {
match provider {
"anthropic" => vec![
"claude-sonnet-4-20250514".into(),
"claude-haiku-4-5-20251001".into(),
"claude-opus-4-20250514".into(),
],
_ => vec!["gpt-4o".into(), "gpt-4o-mini".into(), "gpt-4-turbo".into()],
}
}
async fn fetch_models_async(provider: &str, api_key: &str) -> std::result::Result<Vec<String>, reqwest::Error> {
let client = reqwest::Client::new();
match provider {
"anthropic" => {
let resp: serde_json::Value = client
.get("https://api.anthropic.com/v1/models")
.header("anthropic-version", "2023-06-01")
.header("x-api-key", api_key)
.query(&[("limit", "1000")])
.send()
.await?
.json()
.await?;
let mut models: Vec<String> = resp["data"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| Some(m["id"].as_str()?.to_string()))
.collect()
})
.unwrap_or_default();
models.sort();
Ok(models)
}
"openai" => {
let resp: serde_json::Value = client
.get("https://api.openai.com/v1/models")
.header("Authorization", format!("Bearer {}", api_key))
.send()
.await?
.json()
.await?;
let mut models: Vec<String> = resp["data"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| {
let id = m["id"].as_str()?;
let is_chat = (id.starts_with("gpt-")
|| id.starts_with("o1")
|| id.starts_with("o3")
|| id.starts_with("o4")
|| id.starts_with("chatgpt-"))
&& !id.contains("instruct")
&& !id.contains("realtime")
&& !id.contains("audio")
&& !id.contains("tts")
&& !id.contains("whisper")
&& !id.contains("davinci")
&& !id.contains("babbage");
if is_chat {
Some(id.to_string())
} else {
None
}
})
.collect()
})
.unwrap_or_default();
models.sort();
models.dedup();
Ok(models)
}
_ => Ok(vec![]),
}
}
pub async fn run() -> Result<()> {
init_tracing();
let cli = Cli::parse();
match cli.command {
Some(Commands::Init) => { cmd_init()?; },
Some(Commands::Start { name, headless, price }) => cmd_start(name, headless, price).await?,
Some(Commands::List) => cmd_list()?,
Some(Commands::Status { name }) => cmd_status(&name)?,
Some(Commands::Delete { name }) => cmd_delete(&name)?,
Some(Commands::Config { name }) => cmd_config(&name)?,
Some(Commands::Wallet { name }) => cmd_wallet(&name)?,
Some(Commands::Send { name, address, amount }) => cmd_send(&name, &address, &amount)?,
None => {
print!("{}", style(banner::BANNER).cyan());
println!(" Run {} to get started.\n", style("elisym init").cyan());
Cli::parse_from(["elisym", "--help"]);
}
}
Ok(())
}
fn cmd_init() -> Result<String> {
print!("{}", style(banner::BANNER).cyan());
println!(" {}\n", style("Create a new agent").bold());
println!(" {}", style("Your agent is an AI that lives on the elisym network.").dim());
println!(" {}", style("It can earn SOL by completing tasks for other agents (provider),").dim());
println!(" {}", style("or pay other agents to do work for you (customer).").dim());
println!();
println!(" {}", style("Let's set it up step by step. Type \"back\" at any prompt to go back.").dim());
println!();
let mut name = String::new();
let mut description = String::new();
let mut network = String::new();
let mut rpc_url_opt: Option<String> = None;
let mut provider = String::new();
let mut api_key = String::new();
let mut model = String::new();
let mut max_tokens: u32 = 4096;
let mut encryption_password: Option<Zeroizing<String>> = None;
let mut step: usize = 0;
loop {
match step {
0 => {
println!(" {}", style("A unique name for your agent (used in commands like `start my-agent`)").dim());
let input: String = Input::new()
.with_prompt("Agent name")
.interact_text()?;
if input.is_empty() {
println!(" {} Name cannot be empty.", style("!").yellow());
continue;
}
if config::config_path(&input)?.exists() {
println!(
" {} Agent '{}' already exists. Choose a different name.",
style("!").yellow(),
style(&input).cyan()
);
continue;
}
name = input;
step += 1;
}
1 => {
println!(" {}", style("What does your agent do? This is shown to other agents on the network.").dim());
let input: String = Input::new()
.with_prompt("Description (or \"back\")")
.default("An elisym AI agent".into())
.interact_text()?;
if input.eq_ignore_ascii_case("back") {
step -= 1;
continue;
}
description = input;
step += 1;
}
2 => {
println!(" {}", style("Solana is used for payments between agents.").dim());
println!(" {}", style("Use devnet for testing (free SOL via faucet.solana.com).").dim());
let options = &[
"devnet (default, testing)",
"mainnet (coming soon)",
"testnet (coming soon)",
"\u{2190} Back",
];
let idx = Select::new()
.with_prompt("Solana network")
.items(options)
.default(0)
.interact()?;
if idx == 3 {
step -= 1;
continue;
}
if idx == 1 || idx == 2 {
println!(" {}", style("⚠ This network is not available yet.").yellow());
continue;
}
network = "devnet".to_string();
step += 1;
}
3 => {
println!(" {}", style("Solana RPC endpoint. The default works fine — change only if you have a custom node.").dim());
let default_rpc = match network.as_str() {
"devnet" => "https://api.devnet.solana.com",
"testnet" => "https://api.testnet.solana.com",
_ => "https://api.mainnet-beta.solana.com",
};
let input: String = Input::new()
.with_prompt("RPC URL (or \"back\")")
.default(default_rpc.into())
.interact_text()?;
if input.eq_ignore_ascii_case("back") {
step -= 1;
continue;
}
rpc_url_opt = if input == default_rpc { None } else { Some(input) };
step += 1;
}
4 => {
println!(" {}", style("Your agent uses an LLM to process tasks. Pick a provider and enter your API key.").dim());
let options = &["Anthropic (Claude)", "OpenAI (GPT)", "\u{2190} Back"];
let idx = Select::new()
.with_prompt("LLM provider")
.items(options)
.default(0)
.interact()?;
if idx == 2 {
step -= 1;
continue;
}
provider = match idx {
0 => "anthropic".to_string(),
_ => "openai".to_string(),
};
step += 1;
}
5 => {
let label = if provider == "anthropic" {
"Anthropic (Claude)"
} else {
"OpenAI (GPT)"
};
let input: String = Password::new()
.with_prompt(format!("{} API key (or \"back\")", label))
.interact()?;
if input.eq_ignore_ascii_case("back") {
step -= 1;
continue;
}
if input.is_empty() {
println!(" {} API key cannot be empty.", style("!").yellow());
continue;
}
api_key = input;
step += 1;
}
6 => {
println!(" {}", style("Which model your agent will use. Faster models = lower cost, smarter models = better results.").dim());
let mut models = fetch_models(&provider, &api_key);
models.push("\u{2190} Back".to_string());
let idx = Select::new()
.with_prompt("Model")
.items(&models)
.default(0)
.interact()?;
if idx == models.len() - 1 {
step -= 1;
continue;
}
model = models[idx].clone();
step += 1;
}
7 => {
println!(" {}", style("Maximum length of each LLM response (in tokens). 4096 is good for most tasks.").dim());
let input: String = Input::new()
.with_prompt("Max tokens per LLM response (or \"back\")")
.default("4096".into())
.interact_text()?;
if input.eq_ignore_ascii_case("back") {
step -= 1;
continue;
}
match input.parse::<u32>() {
Ok(val) if val > 0 => {
max_tokens = val;
step += 1;
}
_ => {
println!(" {} Invalid number.", style("!").yellow());
}
}
}
8 => {
println!(" {}", style("Set a password to encrypt secret keys (Nostr, Solana, API keys).").dim());
println!(" {}", style("Leave empty for no encryption. Type \"back\" to go back.").dim());
let pw: String = Password::new()
.with_prompt("Password (empty = no encryption)")
.allow_empty_password(true)
.interact()?;
if pw.eq_ignore_ascii_case("back") {
step -= 1;
continue;
}
if pw.is_empty() {
encryption_password = None;
println!(" {} Keys will be stored unencrypted.", style("~").dim());
} else {
let pw_confirm: String = Password::new()
.with_prompt("Confirm password")
.allow_empty_password(true)
.interact()?;
if pw != pw_confirm {
println!(" {} Passwords don't match.", style("!").yellow());
continue;
}
encryption_password = Some(Zeroizing::new(pw));
println!(" {} Keys will be encrypted.", style("*").green());
}
step += 1;
}
_ => break,
}
}
let keys = Keys::generate();
let secret_key = keys.secret_key().to_secret_hex();
let solana_keypair = Keypair::new();
let solana_secret_key = bs58::encode(solana_keypair.to_bytes()).into_string();
let solana_address = solana_keypair.pubkey().to_string();
let llm_section = LlmSection {
provider: provider.clone(),
api_key,
model,
max_tokens,
};
let mut cfg = AgentConfig {
name: name.clone(),
description,
capabilities: vec!["general".to_string()],
relays: vec![
"wss://relay.damus.io".into(),
"wss://nos.lol".into(),
"wss://relay.nostr.band".into(),
],
secret_key,
payment: PaymentSection {
chain: "solana".to_string(),
network: network.clone(),
rpc_url: rpc_url_opt,
job_price: 10_000_000, payment_timeout_secs: 120,
solana_secret_key,
},
llm: Some(llm_section),
customer_llm: None,
encryption: None,
recovery: Default::default(),
};
if let Some(ref password) = encryption_password {
cfg.encrypt_secrets(password)?;
}
config::save_config(&cfg)?;
let npub = keys.public_key().to_bech32().unwrap_or_default();
println!("\n {} Agent {} created!", style("✓").green().bold(), style(&name).cyan().bold());
println!();
println!(" {} {}", style("npub").dim(), style(&npub).dim());
println!(" {} {}", style("wallet").dim(), style(&solana_address).dim());
println!(" {} {}", style("network").dim(), style(&network).cyan());
println!(" {} {}", style("config").dim(), style(config::config_path(&name)?.display()).dim());
if network != "mainnet" {
println!(
"\n Get devnet SOL {}",
style("https://faucet.solana.com").cyan()
);
}
println!(" Start agent {}\n", style(format!("elisym start {}", name)).cyan());
Ok(name)
}
fn cmd_config(name: &str) -> Result<()> {
let mut cfg = config::load_config(name)?;
let password: Option<Zeroizing<String>> = if cfg.is_encrypted() {
let p = Zeroizing::new(Password::new()
.with_prompt("Password")
.interact()?);
cfg.decrypt_secrets(&p)?;
Some(p)
} else {
None
};
println!("{}\n", style(format!("Configure agent: {}", name)).bold());
loop {
let main_options = &["Provider settings", "Done"];
let main_idx = Select::new()
.with_prompt("Settings")
.items(main_options)
.default(0)
.interact()?;
match main_idx {
0 => {
loop {
let sub_options = &["Job price", "LLM provider", "\u{2190} Back"];
let sub_idx = Select::new()
.with_prompt("Provider settings")
.items(sub_options)
.default(0)
.interact()?;
match sub_idx {
0 => {
println!(
" {} Current price: {} SOL",
style("~").dim(),
format_sol(cfg.payment.job_price)
);
loop {
let input: String = Input::new()
.with_prompt("Job price in SOL")
.default(format_sol(cfg.payment.job_price))
.interact_text()?;
match sol_to_lamports(&input) {
Some(new_price) => {
if let Some(err) = agent::validate_job_price(new_price) {
println!(" {} {}", style("!").yellow(), err);
continue;
}
cfg.payment.job_price = new_price;
save_config_encrypted(&mut cfg, &password)?;
println!(
" {} Price set to {} SOL",
style("*").green(),
format_sol(cfg.payment.job_price)
);
break;
}
_ => {
println!(" {} Invalid amount.", style("!").yellow());
}
}
}
}
1 => {
if let Some(ref llm) = cfg.llm {
println!(
" {} Current: {} / {}",
style("~").dim(),
style(&llm.provider).cyan(),
style(&llm.model).cyan().bold(),
);
let llm_options = &[
"Change model",
"Change max tokens",
"Change provider + API key",
"\u{2190} Back",
];
let llm_idx = Select::new()
.with_prompt("LLM settings")
.items(llm_options)
.default(0)
.interact()?;
match llm_idx {
0 => {
let llm = cfg.llm.as_ref().unwrap();
let mut models = fetch_models(&llm.provider, &llm.api_key);
if let Some(pos) = models.iter().position(|m| m == &llm.model) {
models.swap(0, pos);
}
let model_idx = Select::new()
.with_prompt("Model")
.items(&models)
.default(0)
.interact()?;
cfg.llm.as_mut().unwrap().model = models[model_idx].clone();
save_config_encrypted(&mut cfg, &password)?;
println!(
" {} Model set to {}",
style("*").green(),
style(&cfg.llm.as_ref().unwrap().model).cyan().bold(),
);
}
1 => {
let current = cfg.llm.as_ref().unwrap().max_tokens;
let new_tokens: u32 = Input::new()
.with_prompt("Max tokens per LLM response")
.default(current)
.interact_text()?;
cfg.llm.as_mut().unwrap().max_tokens = new_tokens;
save_config_encrypted(&mut cfg, &password)?;
println!(" {} Max tokens set to {}", style("*").green(), new_tokens);
}
2 => {
if let Some(llm) = prompt_llm_section()? {
cfg.llm = Some(llm);
save_config_encrypted(&mut cfg, &password)?;
println!(" {} Provider LLM saved.", style("*").green());
}
}
_ => {}
}
} else {
if let Some(llm) = prompt_llm_section()? {
cfg.llm = Some(llm);
save_config_encrypted(&mut cfg, &password)?;
println!(" {} Provider LLM saved.", style("*").green());
}
}
}
_ => break,
}
}
}
_ => break,
}
}
println!(
"\n {} All changes saved for '{}'.",
style("*").green(),
style(name).cyan()
);
println!(" Restart agent to apply changes.");
Ok(())
}
fn prompt_llm_section() -> Result<Option<LlmSection>> {
let provider_options = &["Anthropic (Claude)", "OpenAI (GPT)", "\u{2190} Back"];
let provider_idx = Select::new()
.with_prompt("LLM provider")
.items(provider_options)
.default(0)
.interact()?;
if provider_idx == 2 {
return Ok(None);
}
let provider = match provider_idx {
0 => "anthropic",
_ => "openai",
};
let label = provider_options[provider_idx];
let api_key: String = Password::new()
.with_prompt(format!("{} API key", label))
.interact()?;
if api_key.is_empty() {
println!(" {} API key cannot be empty.", style("!").yellow());
return Ok(None);
}
let models = fetch_models(provider, &api_key);
let model_idx = Select::new()
.with_prompt("Model")
.items(&models)
.default(0)
.interact()?;
let max_tokens: u32 = Input::new()
.with_prompt("Max tokens per LLM response")
.default(4096)
.interact_text()?;
Ok(Some(LlmSection {
provider: provider.to_string(),
api_key,
model: models[model_idx].to_string(),
max_tokens,
}))
}
async fn cmd_start(name: Option<String>, headless: bool, price: Option<String>) -> Result<()> {
let name = match name {
Some(n) => n,
None => select_or_create_agent()?,
};
let mut cfg = config::load_config(&name)?;
let enc_password: Option<Zeroizing<String>> = if cfg.is_encrypted() {
let p = Zeroizing::new(Password::new()
.with_prompt(format!("Password for '{}'", name))
.interact()?);
cfg.decrypt_secrets(&p)?;
Some(p)
} else {
None
};
println!("{}", style(banner::BANNER).cyan());
println!(" Starting agent {}...\n", style(&name).cyan().bold());
let solana = agent::build_solana_provider(&cfg)?;
display_wallet_status(&solana, &cfg)?;
let balance = solana.balance().unwrap_or(0);
if balance == 0 && cfg.payment.network != "mainnet" {
println!(
"\n {} Wallet is empty. Get devnet SOL: {}",
style("!").yellow(),
style("https://faucet.solana.com").cyan()
);
}
drop(solana);
println!();
if cfg.llm.is_none() {
println!(" {} No LLM configured. Let's set one up.\n", style("!").yellow());
match prompt_llm_section()? {
Some(llm_section) => {
cfg.llm = Some(llm_section);
save_config_encrypted(&mut cfg, &enc_password)?;
println!(" {} LLM saved.\n", style("*").green());
}
None => {
return Err(CliError::Other("LLM is required to run skills".into()));
}
}
}
let skills_dir = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.join("skills");
let all_skills = crate::skill::loader::load_skills_from_dir(&skills_dir)?;
if all_skills.is_empty() {
println!(
" {} No skills found in {}\n",
style("!").yellow(),
style(skills_dir.display()).dim()
);
println!(" Create a skill directory with a SKILL.md to get started.");
println!(" Example: ./skills/my-skill/SKILL.md\n");
return Err(CliError::Other("no skills configured".into()));
}
let selected_skill = if all_skills.len() == 1 {
println!(" {} Skill: {} — {}",
style("*").green(),
style(all_skills[0].name()).cyan().bold(),
style(all_skills[0].description()).dim(),
);
all_skills.into_iter().next().unwrap()
} else {
let skill_names: Vec<String> = all_skills
.iter()
.map(|s| format!("{} — {}", s.name(), s.description()))
.collect();
let idx = Select::new()
.with_prompt("Select skill")
.items(&skill_names)
.default(0)
.interact()?;
println!();
let mut skills = all_skills;
skills.swap_remove(idx)
};
let mut registry = crate::skill::SkillRegistry::new();
let skill_name = selected_skill.name().to_string();
let skill_description = selected_skill.description().to_string();
let skill_caps: Vec<String> = selected_skill.capabilities().to_vec();
registry.register(selected_skill);
if let Some(ref price_str) = price {
let lamports = sol_to_lamports(price_str)
.ok_or_else(|| CliError::Other(format!("invalid --price value: {}", price_str)))?;
if let Some(err) = agent::validate_job_price(lamports) {
return Err(CliError::Other(err));
}
if lamports != cfg.payment.job_price {
cfg.payment.job_price = lamports;
save_config_encrypted(&mut cfg, &enc_password)?;
}
if cfg.payment.job_price == 0 {
println!(
" {} FREE MODE — payments disabled, jobs processed for free\n",
style("!").yellow().bold()
);
} else {
println!(
" {} Price: {} SOL per job\n",
style("*").green(),
style(format_sol(cfg.payment.job_price)).green().bold()
);
}
} else {
println!(" {}", style("How much to charge per job (in SOL). 3% protocol fee is deducted. Enter 0 for free mode.").dim());
loop {
let price_input: String = Input::new()
.with_prompt("Job price in SOL")
.default(format_sol(cfg.payment.job_price))
.interact_text()?;
match sol_to_lamports(&price_input) {
Some(new_price) => {
if let Some(err) = agent::validate_job_price(new_price) {
println!(" {} {}", style("!").yellow(), err);
continue;
}
if new_price != cfg.payment.job_price {
cfg.payment.job_price = new_price;
save_config_encrypted(&mut cfg, &enc_password)?;
}
if cfg.payment.job_price == 0 {
println!(
" {} FREE MODE — payments disabled\n",
style("!").yellow().bold()
);
} else {
println!(
" {} Price: {} SOL per job\n",
style("*").green(),
style(format_sol(cfg.payment.job_price)).green().bold()
);
}
break;
}
_ => {
println!(" {} Invalid amount.", style("!").yellow());
}
}
}
}
let mut skill_config_changed = false;
if skill_description != cfg.description {
cfg.description = skill_description;
skill_config_changed = true;
}
if skill_caps != cfg.capabilities {
cfg.capabilities = skill_caps.clone();
skill_config_changed = true;
}
if skill_config_changed {
save_config_encrypted(&mut cfg, &enc_password)?;
}
let llm_client = Arc::new(llm::LlmClient::new(
cfg.llm.as_ref().expect("LLM already validated above"),
)?);
let ctx = crate::skill::SkillContext {
llm: Some(llm_client),
agent_name: cfg.name.clone(),
agent_description: cfg.description.clone(),
event_tx: None, };
let runtime_config = crate::runtime::RuntimeConfig {
job_price: cfg.payment.job_price,
payment_timeout_secs: cfg.payment.payment_timeout_secs,
max_concurrent_jobs: 10,
recovery_max_retries: cfg.recovery.max_retries,
recovery_interval_secs: cfg.recovery.interval_secs,
network: cfg.payment.network.clone(),
};
let agent_node = with_spinner(
"Connecting to relays and publishing capabilities...",
agent::build_agent(&cfg),
).await?;
let agent_node = Arc::new(agent_node);
let wallet_balance = {
let solana2 = agent::build_solana_provider(&cfg)?;
solana2.balance().unwrap_or(0)
};
let (event_tx, event_rx) = crate::tui::create_event_channel();
let ledger = Arc::new(tokio::sync::Mutex::new(
crate::ledger::JobLedger::open(&name)?,
));
let ctx = crate::skill::SkillContext {
event_tx: Some(event_tx.clone()),
..ctx
};
let mut runtime = crate::runtime::AgentRuntime::new(
Arc::clone(&agent_node),
registry,
ctx,
runtime_config,
event_tx.clone(),
Arc::clone(&ledger),
);
let transport: Box<dyn crate::transport::Transport> = Box::new(
crate::transport::nostr::NostrTransport::new(
agent_node,
vec![elisym_core::DEFAULT_KIND_OFFSET],
event_tx.clone(),
cfg.recovery.delivery_retries,
),
);
if headless {
println!(" {} Running in headless mode (no TUI)\n", style("*").green());
let mut event_rx = event_rx;
let log_handle = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
log_event(&event);
}
});
let result = runtime.run(transport).await;
drop(log_handle);
result?;
} else {
silence_tracing();
let (retry_tx, retry_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
runtime.set_retry_rx(retry_rx);
let gc = global_config::load_global_config();
let mut app = crate::tui::App::new(
name.clone(),
skill_name,
cfg.payment.job_price,
wallet_balance,
cfg.payment.network.clone(),
gc.tui.sound_enabled,
gc.tui.sound_volume,
);
app.set_ledger(Arc::clone(&ledger));
app.set_retry_tx(retry_tx);
crate::tui::event::run_tui(app, event_rx, runtime, transport).await?;
}
Ok(())
}
fn select_or_create_agent() -> Result<String> {
let agents = config::list_agents()?;
if agents.is_empty() {
println!("No agents configured. Running init wizard...\n");
return cmd_init();
}
let mut options: Vec<String> = agents.clone();
options.push("+ Create new agent".into());
let idx = Select::new()
.with_prompt("Select agent to start")
.items(&options)
.default(0)
.interact()?;
if idx == agents.len() {
return cmd_init();
}
Ok(agents[idx].clone())
}
fn cmd_list() -> Result<()> {
let agents = config::list_agents()?;
if agents.is_empty() {
println!("No agents configured. Run {} to create one.", style("elisym init").cyan());
return Ok(());
}
println!("{}", style("Configured agents:").bold());
for name in &agents {
match config::load_config_public(name) {
Ok(cfg) => {
println!(
" {} — {} [{}]",
style(name).cyan(),
cfg.description,
cfg.capabilities.join(", ")
);
}
Err(_) => {
println!(" {} — {}", style(name).cyan(), style("(config error)").red());
}
}
}
Ok(())
}
fn cmd_status(name: &str) -> Result<()> {
let cfg = config::load_config_public(name)?;
println!("{}", style(format!("Agent: {}", cfg.name)).bold());
println!(" description: {}", cfg.description);
println!(" capabilities: {}", cfg.capabilities.join(", "));
println!(" relays: {}", cfg.relays.join(", "));
println!(" chain: {}", cfg.payment.chain);
println!(" network: {}", cfg.payment.network);
println!(
" job price: {} SOL ({} lamports)",
format_sol(cfg.payment.job_price),
cfg.payment.job_price
);
if let Some(addr) = cfg.payment.solana_address() {
println!(" wallet: {}", addr);
}
if let Some(ref llm) = cfg.llm {
println!(" llm provider: {}", llm.provider);
println!(" llm model: {}", llm.model);
println!(" max tokens: {}", llm.max_tokens);
} else {
println!(" llm: {}", style("not configured").dim());
}
println!(" config: {}", config::config_path(name)?.display());
Ok(())
}
fn cmd_delete(name: &str) -> Result<()> {
let confirmed = Confirm::new()
.with_prompt(format!("Delete agent '{}' and all its data?", name))
.default(false)
.interact()?;
if !confirmed {
println!("Cancelled.");
return Ok(());
}
config::delete_agent(name)?;
println!("Deleted agent '{}'.", style(name).cyan());
Ok(())
}
fn cmd_wallet(name: &str) -> Result<()> {
let mut cfg = config::load_config(name)?;
if cfg.is_encrypted() {
unlock_config(&mut cfg)?;
}
print!("{}", style(banner::BANNER).cyan());
println!(
" Wallet for agent {}\n",
style(name).cyan().bold()
);
let solana = agent::build_solana_provider(&cfg)?;
display_wallet_status(&solana, &cfg)?;
Ok(())
}
fn cmd_send(name: &str, address: &str, amount: &str) -> Result<()> {
let mut cfg = config::load_config(name)?;
if cfg.is_encrypted() {
unlock_config(&mut cfg)?;
}
let dest_pubkey: solana_sdk::pubkey::Pubkey = address
.parse()
.map_err(|_| CliError::Other(format!("invalid Solana address: {}", address)))?;
let solana = agent::build_solana_provider(&cfg)?;
let own_address = solana.address();
if dest_pubkey.to_string() == own_address {
println!(
" {} Destination is the agent's own wallet ({})",
style("!").yellow().bold(),
style(&own_address).dim(),
);
let proceed = Confirm::new()
.with_prompt("Send to yourself?")
.default(false)
.interact()?;
if !proceed {
println!(" Cancelled.");
return Ok(());
}
}
let base_amount = sol_to_lamports(amount)
.ok_or_else(|| CliError::Other(format!("invalid SOL amount: {}", amount)))?;
let unit_label = "SOL";
let balance = solana.balance().unwrap_or(0);
println!(" Balance: {} SOL ({} lamports)", format_sol(balance), balance);
println!(
" Sending {} {} to {}",
style(format_sol_compact(base_amount)).bold(),
unit_label,
style(address).dim()
);
let confirmed = Confirm::new()
.with_prompt("Confirm send?")
.default(false)
.interact()?;
if !confirmed {
println!(" Cancelled.");
return Ok(());
}
let reference = Keypair::new().pubkey().to_string();
let request_json = serde_json::json!({
"recipient": address,
"amount": base_amount,
"reference": reference,
}).to_string();
use elisym_core::PaymentProvider;
match solana.pay(&request_json) {
Ok(result) => {
println!(
"\n {} Sent {} {}",
style("*").green(),
style(format_sol_compact(base_amount)).bold(),
unit_label
);
println!(" Signature: {}", style(&result.payment_id).dim());
if let Ok(new_balance) = solana.balance() {
println!(
" Balance: {} SOL ({} lamports)",
format_sol(new_balance),
new_balance
);
}
}
Err(e) => {
println!(" {} Send failed: {}", style("!").red(), e);
return Err(e.into());
}
}
Ok(())
}
fn display_wallet_status(solana: &elisym_core::SolanaPaymentProvider, cfg: &AgentConfig) -> Result<()> {
let address = solana.address();
let balance = solana.balance().unwrap_or(0);
println!("\n {} {}", style("◆").cyan(), style("Wallet").bold());
println!(" Network {}", style(&cfg.payment.network).cyan());
println!(" Address {}", style(&address).dim());
println!(
" Balance {} SOL {}",
style(format_sol(balance)).green().bold(),
style(format!("({} lamports)", balance)).dim(),
);
Ok(())
}
fn unlock_config(cfg: &mut config::AgentConfig) -> Result<()> {
if !cfg.is_encrypted() {
return Ok(());
}
let max_attempts = 3;
for attempt in 1..=max_attempts {
let password = Zeroizing::new(Password::new()
.with_prompt("Password")
.interact()?);
match cfg.decrypt_secrets(&password) {
Ok(()) => return Ok(()),
Err(e) if attempt == max_attempts => return Err(e),
Err(_) => {
println!(
" {} Wrong password ({}/{})",
style("!").yellow(),
attempt,
max_attempts,
);
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
}
unreachable!()
}
fn log_event(event: &crate::tui::AppEvent) {
use crate::tui::AppEvent;
match event {
AppEvent::JobReceived { job_id, customer_id, .. } => {
println!("[job] received {} from {}", &job_id[..12.min(job_id.len())], &customer_id[..12.min(customer_id.len())]);
}
AppEvent::PaymentRequested { job_id, price, fee } => {
println!("[job] {} requesting payment: {} lamports (fee {})", &job_id[..12.min(job_id.len())], price, fee);
}
AppEvent::PaymentReceived { job_id, net_amount } => {
println!("[job] {} payment received: {} lamports", &job_id[..12.min(job_id.len())], net_amount);
}
AppEvent::PaymentTimeout { job_id } => {
println!("[job] {} payment timeout", &job_id[..12.min(job_id.len())]);
}
AppEvent::SkillStarted { job_id, skill_name } => {
println!("[job] {} skill started: {}", &job_id[..12.min(job_id.len())], skill_name);
}
AppEvent::LlmRound { job_id, round, max_rounds } => {
println!("[job] {} LLM round {}/{}", &job_id[..12.min(job_id.len())], round, max_rounds);
}
AppEvent::ToolStarted { job_id, tool_name } => {
println!("[job] {} tool started: {}", &job_id[..12.min(job_id.len())], tool_name);
}
AppEvent::ToolCompleted { job_id, tool_name, output_len } => {
println!("[job] {} tool completed: {} ({} bytes)", &job_id[..12.min(job_id.len())], tool_name, output_len);
}
AppEvent::ToolFailed { job_id, tool_name, error } => {
println!("[job] {} tool failed: {} — {}", &job_id[..12.min(job_id.len())], tool_name, error);
}
AppEvent::JobCompleted { job_id, result_len } => {
println!("[job] {} completed ({} bytes)", &job_id[..12.min(job_id.len())], result_len);
}
AppEvent::JobFailed { job_id, error } => {
println!("[job] {} failed: {}", &job_id[..12.min(job_id.len())], error);
}
AppEvent::WalletBalance(bal) => {
println!("[wallet] balance: {} lamports", bal);
}
AppEvent::Ping { from } => {
println!("[ping] from {}", &from[..12.min(from.len())]);
}
}
}
fn save_config_encrypted(cfg: &mut config::AgentConfig, password: &Option<Zeroizing<String>>) -> Result<()> {
if let Some(ref p) = password {
cfg.encrypt_secrets(p)?;
config::save_config(cfg)?;
cfg.decrypt_secrets(p)?;
} else {
config::save_config(cfg)?;
}
Ok(())
}