use super::Output;
pub async fn dispatch(cmd: &str, args: &[String], identity: Option<&str>) -> Option<Output> {
match cmd {
"lh-whoami" => Some(match identity {
Some(a) => Output::ok(format!("{a}\n")),
None => Output::err("lh-whoami: no identity on this host", 1),
}),
"lh-balance" => Some(lh_balance(args, identity).await),
"lh-meter" => Some(lh_meter(args, identity).await),
"lh-resolve" => Some(lh_resolve(args).await),
"lh-tba" => Some(lh_tba(args).await),
"lh-price" => Some(lh_price(args).await),
"lh-list" => Some(lh_list(args, identity).await),
"lh-discover" => Some(lh_discover(args).await),
"lh-bounties" => Some(lh_bounties(args).await),
"lh-help" => Some(lh_help()),
_ => None,
}
}
fn lh_help() -> Output {
Output::ok(
"localharnesslite — platform commands for bashlite\n\
\n\
reads (no signer, no gas):\n\
\x20 lh-whoami this host's identity address\n\
\x20 lh-balance [name|0xaddr] wallet $LH balance (default: self)\n\
\x20 lh-meter [name|0xaddr] metered $LH balance (default: self)\n\
\x20 lh-resolve <name> name -> owner address\n\
\x20 lh-tba <name> name -> token-bound account (payment target)\n\
\x20 lh-price <name> agent's advertised per-call $LH price\n\
\x20 lh-list [name|0xaddr] agents owned (default: self)\n\
\x20 lh-discover <query...> find agents by relevance\n\
\x20 lh-bounties [query...] open paid work\n\
\x20 lh-help this list\n\
\n\
writes (confirm-gated):\n\
\x20 lh-send <name|0xaddr> <amount> transfer $LH\n",
)
}
async fn lh_bounties(args: &[String]) -> Output {
const SCAN: u64 = 100;
let query = args.join(" "); match crate::registry::discover_bounties(&query, SCAN).await {
Ok(bounties) => {
let mut out = String::new();
for (id, task, reward) in bounties {
let task = task.replace(['\n', '\r'], " ");
out.push_str(&format!("#{id} {} $LH: {}\n", fmt_lh(reward), task.trim()));
}
Output::ok(out)
}
Err(e) => Output::err(format!("lh-bounties: {e}"), 1),
}
}
async fn lh_discover(args: &[String]) -> Output {
if args.is_empty() {
return Output::err("lh-discover: usage: lh-discover <query…>", 2);
}
const SCAN: u64 = 100;
let query = args.join(" ");
match crate::registry::discover_agents(&query, SCAN).await {
Ok(matches) => {
let mut out = String::new();
for (name, _persona) in matches {
out.push_str(&name);
out.push('\n');
}
Output::ok(out)
}
Err(e) => Output::err(format!("lh-discover: {e}"), 1),
}
}
async fn subject_address(
args: &[String],
identity: Option<&str>,
cmd: &str,
) -> Result<String, Output> {
match args.first() {
Some(a) if a.starts_with("0x") => Ok(a.clone()),
Some(name) => match crate::registry::owner_of_name(name).await {
Ok(Some(owner)) => Ok(owner),
Ok(None) => Err(Output::err(format!("{cmd}: {name}: not registered"), 1)),
Err(e) => Err(Output::err(format!("{cmd}: {e}"), 1)),
},
None => match identity {
Some(a) => Ok(a.to_string()),
None => Err(Output::err(format!("{cmd}: no identity — pass a name or 0x address"), 2)),
},
}
}
async fn lh_balance(args: &[String], identity: Option<&str>) -> Output {
let target = match subject_address(args, identity, "lh-balance").await {
Ok(a) => a,
Err(out) => return out,
};
match crate::registry::token_balance_of(&target).await {
Ok(wei) => Output::ok(format!("{}\n", fmt_lh(wei))),
Err(e) => Output::err(format!("lh-balance: {e}"), 1),
}
}
async fn lh_meter(args: &[String], identity: Option<&str>) -> Output {
let target = match subject_address(args, identity, "lh-meter").await {
Ok(a) => a,
Err(out) => return out,
};
match crate::registry::credit_balance_of(&target).await {
Ok(wei) => Output::ok(format!("{}\n", fmt_lh(wei))),
Err(e) => Output::err(format!("lh-meter: {e}"), 1),
}
}
async fn lh_list(args: &[String], identity: Option<&str>) -> Output {
let target = match subject_address(args, identity, "lh-list").await {
Ok(a) => a,
Err(out) => return out,
};
match crate::registry::list_owned_tokens(&target).await {
Ok(tokens) => {
let mut out = String::new();
for t in tokens {
out.push_str(&t.name);
out.push('\n');
}
Output::ok(out)
}
Err(e) => Output::err(format!("lh-list: {e}"), 1),
}
}
async fn lh_resolve(args: &[String]) -> Output {
let Some(name) = args.first() else {
return Output::err("lh-resolve: usage: lh-resolve <name>", 2);
};
let id = match crate::registry::id_of_name(name).await {
Ok(0) => return Output::err(format!("lh-resolve: {name}: not registered"), 1),
Ok(id) => id,
Err(e) => return Output::err(format!("lh-resolve: {e}"), 1),
};
let owner = crate::registry::owner_of_name(name).await.ok().flatten().unwrap_or_default();
let tba = crate::registry::tba_of_name(name).await.ok().flatten().unwrap_or_default();
Output::ok(format!("token_id {id}\nowner {owner}\ntba {tba}\n"))
}
async fn lh_tba(args: &[String]) -> Output {
let Some(name) = args.first() else {
return Output::err("lh-tba: usage: lh-tba <name>", 2);
};
match crate::registry::id_of_name(name).await {
Ok(0) => return Output::err(format!("lh-tba: {name}: not registered"), 1),
Ok(_) => {}
Err(e) => return Output::err(format!("lh-tba: {e}"), 1),
}
match crate::registry::tba_of_name(name).await {
Ok(Some(tba)) => Output::ok(format!("{tba}\n")),
Ok(None) => Output::err(format!("lh-tba: {name}: no token-bound account deployed yet"), 1),
Err(e) => Output::err(format!("lh-tba: {e}"), 1),
}
}
async fn lh_price(args: &[String]) -> Output {
let Some(name) = args.first() else {
return Output::err("lh-price: usage: lh-price <name>", 2);
};
let id = match crate::registry::id_of_name(name).await {
Ok(0) => return Output::err(format!("lh-price: {name}: not registered"), 1),
Ok(id) => id,
Err(e) => return Output::err(format!("lh-price: {e}"), 1),
};
match crate::registry::x402_ask_price_of(id).await {
Ok(wei) => Output::ok(format!("{} $LH\n", fmt_lh(wei))),
Err(e) => Output::err(format!("lh-price: {e}"), 1),
}
}
use k256::ecdsa::SigningKey;
pub struct WriteEnv<'a> {
pub signer: &'a SigningKey,
pub sponsor: &'a SigningKey,
pub fee_token: &'a str,
}
pub async fn dispatch_write(
cmd: &str,
args: &[String],
env: &WriteEnv<'_>,
dry_run: bool,
) -> Option<(Output, String)> {
match cmd {
"lh-send" => Some(lh_send(args, env, dry_run).await),
_ => None,
}
}
pub fn is_write_command(cmd: &str) -> bool {
matches!(cmd, "lh-send")
}
async fn lh_send(args: &[String], env: &WriteEnv<'_>, dry_run: bool) -> (Output, String) {
use crate::encoding::{classify_recipient, parse_token_amount, Recipient};
let none = String::new();
let (Some(recipient), Some(amount)) = (args.first(), args.get(1)) else {
return (Output::err("lh-send: usage: lh-send <name|0xaddr> <amount>", 2), none);
};
let to_hex = match classify_recipient(recipient) {
Ok(Recipient::Address(a)) => a,
Ok(Recipient::Name(n)) => match crate::registry::owner_of_name(&n).await {
Ok(Some(o)) => o,
Ok(None) => return (Output::err(format!("lh-send: {n}: not registered"), 1), none),
Err(e) => return (Output::err(format!("lh-send: {e}"), 1), none),
},
Err(e) => return (Output::err(format!("lh-send: {e}"), 2), none),
};
let amount_wei = match parse_token_amount(amount) {
Some(w) if w > 0 => w,
_ => return (Output::err(format!("lh-send: invalid amount '{amount}'"), 2), none),
};
let plan = format!("send {amount} $LH -> {recipient} ({to_hex})");
if dry_run {
return (Output::ok(format!("[plan] {plan}\n")), plan);
}
match crate::registry::transfer_lh_sponsored(
env.signer,
env.sponsor,
&to_hex,
amount_wei,
env.fee_token,
)
.await
{
Ok(tx) => (Output::ok(format!("sent {amount} $LH -> {to_hex} tx {tx}\n")), plan),
Err(e) => (Output::err(format!("lh-send: {e}"), 1), plan),
}
}
fn fmt_lh(wei: u128) -> String {
const UNIT: u128 = 1_000_000_000_000_000_000;
let whole = wei / UNIT;
let frac = wei % UNIT;
if frac == 0 {
return whole.to_string();
}
let mut f = format!("{frac:018}");
while f.ends_with('0') {
f.pop();
}
format!("{whole}.{f}")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn dispatch_passes_non_lh_commands_through() {
assert!(dispatch("echo", &["hi".into()], None).await.is_none());
assert!(dispatch("ls", &[], Some("0xabc")).await.is_none());
}
#[tokio::test]
async fn help_lists_every_command_offline() {
let r = dispatch("lh-help", &[], None).await.unwrap();
assert_eq!(r.code, 0);
for cmd in [
"lh-whoami", "lh-balance", "lh-meter", "lh-resolve", "lh-tba", "lh-price",
"lh-list", "lh-discover", "lh-bounties", "lh-help", "lh-send",
] {
assert!(r.stdout.contains(cmd), "lh-help is missing `{cmd}`");
}
}
#[tokio::test]
async fn whoami_prints_identity_or_errors() {
let r = dispatch("lh-whoami", &[], Some("0xF00")).await.unwrap();
assert_eq!(r.stdout, "0xF00\n");
assert_eq!(r.code, 0);
let r = dispatch("lh-whoami", &[], None).await.unwrap();
assert_ne!(r.code, 0); }
#[tokio::test]
async fn balance_without_identity_or_arg_is_a_usage_error() {
let r = dispatch("lh-balance", &[], None).await.unwrap();
assert_eq!(r.code, 2);
assert!(r.stderr.contains("identity"));
}
#[tokio::test]
async fn resolve_and_price_require_a_name() {
assert_eq!(dispatch("lh-resolve", &[], None).await.unwrap().code, 2);
assert_eq!(dispatch("lh-price", &[], None).await.unwrap().code, 2);
assert_eq!(dispatch("lh-tba", &[], None).await.unwrap().code, 2);
}
#[tokio::test]
async fn discover_is_dispatched_and_requires_a_query() {
let r = dispatch("lh-discover", &[], None).await;
assert!(r.is_some());
assert_eq!(r.unwrap().code, 2);
}
#[tokio::test]
async fn meter_and_list_are_dispatched_and_subject_gated() {
for cmd in ["lh-meter", "lh-list"] {
let r = dispatch(cmd, &[], None).await;
assert!(r.is_some(), "{cmd} should be dispatched");
let r = r.unwrap();
assert_eq!(r.code, 2, "{cmd} no-arg+no-identity should be a usage error");
assert!(r.stderr.contains("identity"), "{cmd}: {:?}", r.stderr);
}
}
#[test]
fn fmt_lh_trims() {
assert_eq!(fmt_lh(0), "0");
assert_eq!(fmt_lh(2_000_000_000_000_000_000), "2");
assert_eq!(fmt_lh(1_500_000_000_000_000_000), "1.5");
assert_eq!(fmt_lh(10_000_000_000_000_000), "0.01");
}
#[tokio::test]
async fn dispatch_write_dry_run_plans_without_sending() {
let k = crate::wallet::generate();
let env = WriteEnv {
signer: &k.signer,
sponsor: &k.signer,
fee_token: "0x20c0000000000000000000000000000000000001",
};
assert!(dispatch_write("echo", &[], &env, true).await.is_none());
assert!(dispatch_write("lh-resolve", &["x".into()], &env, true).await.is_none());
let addr = "0x00000000000000000000000000000000000000aa".to_string();
let (out, plan) =
dispatch_write("lh-send", &[addr.clone(), "2.5".into()], &env, true).await.unwrap();
assert_eq!(out.code, 0);
assert!(out.stdout.contains("[plan] send 2.5 $LH"), "{:?}", out.stdout);
assert!(plan.contains("send 2.5 $LH"));
let (out, plan) = dispatch_write("lh-send", &[], &env, true).await.unwrap();
assert_eq!(out.code, 2);
assert!(plan.is_empty());
let (out, plan) =
dispatch_write("lh-send", &[addr, "-5".into()], &env, true).await.unwrap();
assert_eq!(out.code, 2);
assert!(plan.is_empty());
}
}