localharness 0.51.0

Agents that own themselves: one Rust crate that's both an agent SDK (streaming, tools, hooks, policies, triggers, MCP) and a wallet-owning, self-sovereign agent that runs in the browser.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! localharnesslite — read-only `lh-*` platform commands for bashlite.
//!
//! These let a bashlite script READ the platform (identity, wallet + meter $LH
//! balances, name resolution, advertised price, owned agents) as plain commands
//! — `lh-whoami`, `lh-balance`, `lh-meter`, `lh-resolve`, `lh-tba`, `lh-price`,
//! `lh-list`, `lh-discover`, `lh-bounties`, `lh-help` — so an agent's intent is a
//! PROGRAM over the platform — not a stutter of tool round-trips, and not an
//! agent loop at all. A surface (CLI / browser) wires [`dispatch`] into its
//! `BashHost::run_builtin` override, falling back to the fs builtins for
//! everything else.
//!
//! READ-ONLY by design: every command here is a registry view (no signer, no
//! gas, no confirm). Value-MOVING `lh-*` (`lh-send`, `lh-create`, …) are v2 and
//! go behind the dry-run-manifest confirm gate (`design/bashlite.md`). Like the
//! fs builtins, these are TOTAL: bad args / RPC failures return a nonzero
//! [`Output`], never a panic.
//!
//! Feature-gated on `wallet` (the `registry` layer); the bashlite core itself
//! stays dependency-free.

use super::Output;

/// Dispatch a read-only `lh-*` command. Returns `None` when `cmd` is not an
/// `lh-` command this core owns (so the host falls through to the fs builtins);
/// `Some(output)` — possibly a nonzero error output — when it is.
///
/// `identity` is the caller's `0x` address (the host's logged-in agent), used as
/// the default subject for `lh-whoami` / `lh-balance`.
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()),
        // Not an lh-* command we own — let the host fall back to fs builtins.
        _ => None,
    }
}

/// `lh-help` — list every localharnesslite command, one per line, so an agent
/// dropped into a bashlite shell can DISCOVER the platform surface without
/// leaving it. Pure, static, read-only; the text doubles as the spec. Keep it in
/// sync as `lh-*` commands are added.
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",
    )
}

/// `lh-bounties [query…]` — open bounties (paid work) one per line:
/// `#<id> <reward> $LH: <task>`. No query lists ALL open bounties; a query ranks
/// by task relevance. The counterpart to `lh-discover` (find agents) — find WORK;
/// claim it with the CLI `bounty claim`.
async fn lh_bounties(args: &[String]) -> Output {
    const SCAN: u64 = 100;
    let query = args.join(" "); // empty = all open bounties (newest-first)
    match crate::registry::discover_bounties(&query, SCAN).await {
        Ok(bounties) => {
            let mut out = String::new();
            for (id, task, reward) in bounties {
                // One bounty = one line (so `wc -l` counts them); flatten the task.
                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),
    }
}

/// `lh-discover <query…>` — find agents by capability (the Agent Yellow Pages),
/// ONE name per line so `for a in $(lh-discover coding); do …; done` fans out
/// over them. The query may be several words (ORed). Empty output = no matches.
async fn lh_discover(args: &[String]) -> Output {
    if args.is_empty() {
        return Output::err("lh-discover: usage: lh-discover <query…>", 2);
    }
    // Scan the most recent agents; matches the CLI `discover` default.
    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),
    }
}

/// Resolve the SUBJECT address of a read command: a `0x…` address verbatim, a
/// name's OWNER, or (no arg) the caller's identity. `Err(output)` is a ready
/// nonzero result. Shared by the address-keyed reads (balance/meter/list).
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)),
        },
    }
}

/// `lh-balance [name|0xaddr]` — the WALLET `$LH` balance of an address, a name's
/// OWNER, or (no arg) the caller's own identity.
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),
    }
}

/// `lh-meter [name|0xaddr]` — the per-call METER `$LH` balance (what the proxy
/// debits per request), distinct from the spendable wallet balance.
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),
    }
}

/// `lh-list [name|0xaddr]` — the agent names an identity owns, ONE per line (so
/// `for a in $(lh-list); do …; done` fans out over them). Empty output = none.
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),
    }
}

/// `lh-resolve <name>` — the token id, owner, and TBA of a registered name.
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"))
}

/// `lh-tba <name>` — JUST the agent's token-bound account address, one line, no
/// label, so it COMPOSES: `lh-send $(lh-tba alice) 5` funds alice's treasury
/// (x402 / bounty payments settle to the TBA, not the owner). `lh-resolve` prints
/// the same TBA among other fields for humans; this is the pipeable form, like
/// `lh-whoami` for an identity. Errors distinguish unregistered from
/// not-yet-deployed.
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),
    }
}

/// `lh-price <name>` — the agent's advertised per-call `$LH` price.
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),
    }
}

// --- value-MOVING lh-* (writes) + the dry-run manifest ----------------------
//
// Writes are NOT dispatched by the read [`dispatch`] above — they need a signer,
// a fee_payer, and the dry-run-manifest confirm flow (`design/bashlite.md`). A
// host that holds an identity calls [`dispatch_write`]: in DRY-RUN the command
// records a one-line plan and moves nothing; the host collects every plan into a
// manifest, confirms the WHOLE manifest once, then re-runs LIVE. Same total
// contract — bad args / RPC errors are a nonzero [`Output`], never a panic.

use k256::ecdsa::SigningKey;

/// What a value-moving command needs from its host: the caller's identity key,
/// the `fee_payer` sponsor, and the chain fee token.
pub struct WriteEnv<'a> {
    pub signer: &'a SigningKey,
    pub sponsor: &'a SigningKey,
    pub fee_token: &'a str,
}

/// Dispatch a VALUE-MOVING `lh-*` command. Returns `None` when `cmd` is not a
/// value-moving command this core owns. `Some((output, plan))`: `plan` is a
/// one-line manifest description when the command WOULD move value (empty when
/// it failed before committing to a move, e.g. bad args). In `dry_run` the
/// `output` is the plan and NOTHING is sent; live, the `output` carries the
/// result (tx hash).
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,
    }
}

/// Whether `cmd` is a value-MOVING command (handled by [`dispatch_write`], gated
/// by the dry-run-manifest confirm flow) — so a host can require an identity +
/// route it through the gate before the read/fs fallbacks.
pub fn is_write_command(cmd: &str) -> bool {
    matches!(cmd, "lh-send")
}

/// `lh-send <name|0xaddr> <amount>` — transfer `$LH` to an address or a name's
/// owner (sponsored; the caller pays no gas).
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),
    }
}

/// Format `$LH` wei (18-dec) as a trimmed decimal string: `1500000000000000000`
/// → `1.5`, `2000000000000000000` → `2`, `0` → `0`.
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() {
        // Anything not `lh-*` → None, so the host falls back to fs builtins.
        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() {
        // Pure, no RPC, exit 0 — and it must mention EVERY lh-* command so the
        // listing can't silently drift as commands are added/renamed.
        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); // no identity → nonzero, not a panic
    }

    #[tokio::test]
    async fn balance_without_identity_or_arg_is_a_usage_error() {
        // No arg + no identity → a clean usage error BEFORE any RPC call.
        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);
        // lh-tba is dispatched and also a name-required usage error before any RPC.
        assert_eq!(dispatch("lh-tba", &[], None).await.unwrap().code, 2);
    }

    #[tokio::test]
    async fn discover_is_dispatched_and_requires_a_query() {
        // Owned by the dispatcher; no query → usage error (exit 2) before any RPC.
        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() {
        // The new reads are owned by the dispatcher (Some), and with no arg + no
        // identity they fail cleanly (usage exit 2) BEFORE any RPC call.
        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",
        };
        // Non-value-moving commands are not ours.
        assert!(dispatch_write("echo", &[], &env, true).await.is_none());
        assert!(dispatch_write("lh-resolve", &["x".into()], &env, true).await.is_none());

        // lh-send to a 0x ADDRESS (no network) in dry-run → a plan, NOTHING sent.
        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"));

        // Bad args / amount → nonzero with an EMPTY plan (no value move recorded).
        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());
    }
}