Extending the CLI in .harn

How to add or port a harn subcommand without writing Rust. Most CLI work is a text/JSON transform, a catalog lookup, a directory walk, or a process spawn — once a port lands, the Rust handler shrinks to a ~5-line dispatch shim and the actual behavior lives in an embedded .harn script.

This is the cookbook side of the harn-cli self-host epic (harn#2293). The reference docs for the primitives the scripts build on are std/cli/argparse, std/cli/render, std/cli/paths, and the harn --json contract.

Architecture

harn-cli keeps top-level clap parsing in Rust — that's how harn gets fast help text, completions, and subcommand dispatch. After parsing, ported subcommands hand control to an embedded .harn script via the dispatch wedge in crates/harn-cli/src/dispatch.rs. The wedge looks the script up in the STDLIB_CLI_SCRIPTS table in crates/harn-stdlib/src/lib.rs, writes the embedded source to a temp file, and runs it through the same harn run code path the user-facing CLI uses. Bytecode cache, harness install, skill loader, store/metadata/checkpoint builtins all come along for free.

The script receives argv: list<string> (everything after the subcommand name, the same global harn run -- a b c exposes) and reads the HARN_OUTPUT_JSON env var to decide between human and JSON output. Build-time constants and other host-provided context travel through scoped env vars rather than new builtins, so the script's contract stays small. Every ported handler keeps its legacy Rust path behind HARN_CLI_IMPL=rust — the parity-snapshot harness flips that switch to assert both implementations produce byte-identical output ([C1 ratchet, #2314]). The flag will go away when the LOC ratchet flat-lines.

Walkthrough — port a fictional harn greet <name>

End-to-end port of a brand-new subcommand. Total diff is about 80 lines across six files. The canonical real-world reference is the harn version port in #2328 (W1) — it's the smallest meaningful port in the tree.

1. Clap args struct

crates/harn-cli/src/cli/greet.rs:

use clap::Args;

#[derive(Debug, Args)]
pub(crate) struct GreetArgs {
    /// Who to greet.
    pub name: String,
    /// Emit a `JsonEnvelope` with `{ greeting }` instead of plain text.
    #[arg(long)]
    pub json: bool,
}

2. Wire into the Command enum

crates/harn-cli/src/cli/mod.rs:

mod greet;
// ...
pub(crate) use greet::GreetArgs;
// ...
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
    // ...
    /// Greet someone by name.
    Greet(GreetArgs),
    // ...
}

3. Dispatch shim

crates/harn-cli/src/commands/greet.rs:

//! `harn greet` dispatch shim. The Rust path stays behind
//! `HARN_CLI_IMPL=rust` until the C1 ratchet (#2314) tightens its
//! budget to zero.

use crate::cli::GreetArgs;
use crate::dispatch;
use crate::env_guard::ScopedEnvVar;

pub(crate) async fn run(args: GreetArgs) -> i32 {
    if std::env::var("HARN_CLI_IMPL").as_deref() == Ok("rust") {
        return run_rust(&args);
    }
    let _name = ScopedEnvVar::set("HARN_GREET_NAME", &args.name);
    let argv = if args.json {
        vec!["--json".to_string()]
    } else {
        Vec::new()
    };
    dispatch::dispatch_to_embedded_script("greet", argv, args.json).await
}

/// Legacy Rust implementation. Kept behind `HARN_CLI_IMPL=rust` for the
/// parity harness; removed when the C1 ratchet flat-lines the budget.
fn run_rust(args: &GreetArgs) -> i32 {
    if args.json {
        println!(r#"{{"schemaVersion":1,"ok":true,"data":{{"greeting":"hello {}"}},"error":null,"warnings":[]}}"#, args.name);
    } else {
        println!("hello {}", args.name);
    }
    0
}

Match the subcommand in the top-level dispatch (lib.rs):

Command::Greet(args) => {
    let exit = commands::greet::run(args).await;
    if exit != 0 {
        process::exit(exit);
    }
}

4. The .harn script

crates/harn-stdlib/src/stdlib/cli/greet.harn:

/**
 * `harn greet` ported to .harn. The name comes in via HARN_GREET_NAME
 * (set by the dispatch shim) so we don't have to re-parse it from argv.
 * JSON mode is gated by HARN_OUTPUT_JSON, the dispatch wedge's
 * standard signal.
 */
fn render_envelope(name: string) -> string {
  let env = {
    schemaVersion: 1,
    ok: true,
    data: {greeting: "hello " + name},
    error: nil,
    warnings: [],
  }
  return json_stringify_pretty(env)
}

fn main(harness: Harness) {
  let name = harness.env.get_or("HARN_GREET_NAME", "world")
  let json_mode = harness.env.get_or("HARN_OUTPUT_JSON", "0") == "1"
  if json_mode {
    harness.stdio.println(render_envelope(name))
  } else {
    harness.stdio.println("hello " + name)
  }
}

5. Register the script

crates/harn-stdlib/src/lib.rs, in STDLIB_CLI_SCRIPTS:

StdlibCliScript {
    name: "greet",
    source: include_str!("stdlib/cli/greet.harn"),
},

The name is the lookup key the dispatch wedge passes in step 3; nested paths like "eval/prompt" are fine too (they're collapsed to eval-prompt- in the temp file prefix so the OS doesn't care).

6. Parity test

crates/harn-cli/tests/greet_dispatch.rs, following the established *_dispatch.rs pattern that version_dispatch.rs, scaffold_dispatch.rs, and eval_prompt_dispatch.rs all use — drive both implementations as subprocesses, flip HARN_CLI_IMPL=rust for the baseline, and structurally compare:

#[test]
fn greet_matches_rust_baseline() {
    let harn = run_subprocess(&["greet", "kenneth"], &[]);
    let rust = run_subprocess(&["greet", "kenneth"], &[("HARN_CLI_IMPL", "rust")]);
    assert_eq!(harn.stdout, rust.stdout);
    assert_eq!(harn.exit_code, 0);
}

#[test]
fn greet_json_envelope_matches() {
    let harn = run_subprocess(&["greet", "kenneth", "--json"], &[]);
    let rust = run_subprocess(&["greet", "kenneth", "--json"], &[("HARN_CLI_IMPL", "rust")]);
    let h: serde_json::Value = serde_json::from_str(&harn.stdout).unwrap();
    let r: serde_json::Value = serde_json::from_str(&rust.stdout).unwrap();
    assert_eq!(h, r);
}

JSON envelopes get compared as parsed serde_json::Value rather than byte-for-byte because Harn's json_stringify_pretty sorts dict keys alphabetically while serde emits struct fields in declaration order. Both serialize the same envelope.

7. Tighten the LOC budget

scripts/ported_handlers.toml (C1 #2314) carries one [[handler]] block per ported subcommand with a max_loc budget. Append a fresh entry for the new shim:

[[handler]]
path = "crates/harn-cli/src/commands/greet.rs"
max_loc = 45   # current+5 slack

The next port to land in this file shrinks the budget to its new current+5. The ratchet itself is a pure-.harn script (scripts/check_ported_handler_loc.harn) wired into make check-ported-handler-loc and .github/workflows/ported-handler-loc.yml.

Argparse cookbook

Common patterns using std/cli/argparse. Every example assumes let result = parse(spec, argv) and a follow-up error check.

Positional + required

import { parser, parse } from "std/cli/argparse"

let spec = parser({
  name: "render",
  args: [
    {name: "template", kind: "positional", required: true,
     help: "Path to the .harn.prompt template."},
  ],
})
let result = parse(spec, argv)

Positionals are required by default — flip to required: false to opt out, or set variadic: true to greedily collect the rest into a list.

Repeated flag

{name: "model", kind: "flag", short: "-m", long: "--model",
 multi: true, value_name: "ID",
 help: "Model id; repeat for multi-model fanout."}

With multi: true, the parsed value is a list<string> (defaulting to [] when unspecified), so -m claude-opus-4-7 -m gpt-5 yields ["claude-opus-4-7", "gpt-5"].

-- separator → rest

argparse always routes everything after a bare -- into parsed.rest, no matter what flags or positionals are still pending:

harn greet -- --not-a-flag "hello world"

parsed.rest is ["--not-a-flag", "hello world"]. Use this to forward verbatim argv to a child process or to a downstream script.

Error envelope handling

parse returns {ok: dict} or {err: ParseError}. Narrow with the standard r.err != nil pattern and let render_help produce the usage block:

import { parser, parse, render_help } from "std/cli/argparse"

let result = parse(spec, argv)
let err = result.err
if err != nil {
  __io_eprintln(render_help(spec))
  __io_eprintln("error: " + err.hint)
  exit(2)
}
let parsed = result.ok

The error kind is one of missing_required, unknown_flag, unknown_arg, value_required, or bad_value. Each carries the offending argv string in arg and a one-line hint.

See the std/cli/argparse reference for the full surface, error catalog, and --help layout contract.

Output rendering

JSON-mode and human-mode rendering split cleanly through std/cli/render:

import { envelope, write_envelope, json_mode } from "std/cli/render"
import { ansi_bold } from "std/ansi"
import { render_table } from "std/table"

fn main(harness: Harness) {
  let items = [{provider: "anthropic", model: "claude-opus-4-7"}]
  if json_mode() {
    write_envelope(envelope({
      schema_version: 1,
      api_stability: "stable",
      payload: {items: items},
    }))
    return
  }
  harness.stdio.println(ansi_bold("Models", {}))
  harness.stdio.println(render_table(items, {
    headers: ["Provider", "Model"],
  }))
}
  • std/ansi handles color and tty detection; NO_COLOR and HARN_COLOR are honored automatically.
  • std/table renders aligned tables, markdown tables, and key/value tables with column auto-width and per-cell truncation.
  • std/cli/render layers the envelope() / write_envelope() helpers on top so JSON output stays snapshot-test friendly with a pinned top-level key order (schemaVersion, apiStability, optional warnings, then payload).

json_mode() reads HARN_OUTPUT_JSON so the script doesn't need to re-parse its own --json flag — the dispatch wedge already saw the host's choice.

See the std/cli/render reference for the full envelope contract and the harn --json contract for the agent-facing version-bump discipline.

Config, data, and cache paths

CLI scripts that need app-specific directories should use std/cli/paths:

import { xdg_cache_home, xdg_config_home, xdg_data_home } from "std/cli/paths"

let config_dir = xdg_config_home("harn")
let data_dir = xdg_data_home("harn")
let cache_dir = xdg_cache_home("harn")

The helpers honor absolute XDG env vars first, ignore relative XDG env vars, use ~/Library/Application Support and ~/Library/Caches on macOS when XDG is unset, and fall back to the standard $HOME/.config, $HOME/.local/share, and $HOME/.cache roots elsewhere. They only resolve paths; call harness.fs.mkdir(...) if a script needs to create the directory.

Adding a host capability

When a port discovers a gap in the harness.* namespace — something the Rust handler does that no .harn script can — the answer is a new builtin via the G4 pattern (#2297). G4 landed a first round of free builtins (term_width, term_height, mkdtemp, glob, llm_catalog, llm_provider_status) that today live as top-level functions so the ports could move. Directory policy that can be expressed from env vars belongs in std/cli/paths instead of a host capability. spawn_captured has since moved to harness.process.spawn_captured, sha256_hex is a compatibility alias for harness.crypto.sha256, and the LLM catalog helpers have moved to harness.llm.catalog() and harness.llm.providers(). Prefer the canonical harness.X.Y sub-handle when the script receives a Harness parameter; top-level helpers remain aliases for scripts that run outside that shape. Add new capabilities the same way:

  1. Register the builtin in crates/harn-vm/src/stdlib/<area>.rs with a #[harn_builtin] annotation (see Adding a stdlib builtin) and wire the matching Harness accessor.
  2. Drop a conformance fixture under conformance/tests/host_<cap>_* pinning the contract.
  3. Document the capability under docs/src/host-capabilities/ and cross-link from the parent index.

Keep the builtin small and orthogonal — single capability, single return shape, no implicit side-effects. If a port wants something that feels like an editorial decision (a specific output format, a UI choice), keep that in the .harn script and add the smallest possible primitive instead.

Performance budget

Cold-start matters more than steady-state for the CLI. Every ported subcommand has to meet a wall-clock budget pinned in perf/cli/budgets.toml and gated by make bench-cli-cold-start. The gate runs each subcommand under a cold bytecode cache, samples a fixed number of invocations, and asserts the median stays under the budget.

make bench-cli-cold-start

Two ratchets work together to keep the port honest:

  • make bench-cli-cold-start (G5 / perf/cli/budgets.toml) fails when a ported script's cold-start regresses. Bump the budget only with a rationale comment — re-tuning the budget is the loudest possible signal that a port got slower.
  • make check-ported-handler-loc (C1 #2314 / scripts/ported_handlers.toml) fails when a Rust handler grows back past its committed line count. When a port advances and shrinks a shim, drop the budget to the new current+5 in the same PR. When a new handler joins the ratcheted set, append a fresh [[handler]] block with its current+5 budget — never retroactively tighten the existing entries.

The bytecode cache (HARN_BYTECODE_CACHE) amortizes parse + typecheck across invocations after the first one; the cold-start gate measures the worst case. If a port can't meet its budget, the usual fixes are trimming imports from the script (only pull in what main actually uses), avoiding redundant json_stringify round-trips, and pushing per-call allocation off the hot path.