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/ansihandles color and tty detection;NO_COLORandHARN_COLORare honored automatically.std/tablerenders aligned tables, markdown tables, and key/value tables with column auto-width and per-cell truncation.std/cli/renderlayers theenvelope()/write_envelope()helpers on top so JSON output stays snapshot-test friendly with a pinned top-level key order (schemaVersion,apiStability, optionalwarnings, thenpayload).
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:
- Register the builtin in
crates/harn-vm/src/stdlib/<area>.rswith a#[harn_builtin]annotation (see Adding a stdlib builtin) and wire the matchingHarnessaccessor. - Drop a conformance fixture under
conformance/tests/host_<cap>_*pinning the contract. - 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 newcurrent+5in 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.