Skip to content

Library M8: the CLI as a library

M8 is the eighth of the eleven milestones of #48, which turns Henad into a library published on crates.io. henad-cli gains src/lib.rs with CliOptions, SOME_RUNS_NOT_OK and run(options, arguments) -> u8, and the henad-cli binary is three lines over run with example_models(). henad-models is an optional dependency behind the default example-models feature, which the binary requires, and cargo build -p henad-cli --no-default-features builds the library alone. The command line keeps every output byte for byte: the golden tests, every henad-cli test, --help, --version, the usage and run errors with their exit codes, and the --json lines of five benchmarks apart from their timings. The interleaved comparison against the M7 binary passed on all four configurations.

State before

48-library stood at 654539e, M7's commit, with a clean tree. henad-cli had a binary target alone, src/main.rs with the crate doc, Args, Mode, main, the benchmark and the exports, beside the private explore and json_report modules. main installed the panic hook, parsed through clap's derived Parser::parse under the fixed name henad-cli and henad-cli's own version, built example_models(), and returned anyhow::Result<ExitCode>. explore.rs held SOME_RUNS_NOT_OK, built the sweep's Provenance from build_info!() and std::env::args_os(), and returned ExitCode. The info line's engine_version read henad-cli's CARGO_PKG_VERSION. existing_invocations_keep_their_mode read the command lines of main.rs and docs/reference/cli.md, with a floor of 12 documented lines that are not sweeps.

What was done

The library

src/main.rs moved to src/lib.rs with git mv, and the binary is a new main.rs. The rest of [4.5.2] follows.

  • CliOptions holds the ModelSet, the host's BuildInfo and the command name. CliOptions::new(models, host) takes the name from host.package(), and command_name sets another. It derives Debug and Clone.
  • SOME_RUNS_NOT_OK moved from explore.rs to the crate root as the public u8 3.
  • run(options, arguments) -> u8 installs the panic hook, collects the arguments, and parses them through Args::command() with Command::name and Command::version from the options, then from_arg_matches_mut, as Parser::try_parse_from does. A clap error prints through Error::print and returns its own code, 0 for --help and --version and 2 for a usage error, which is what Error::exit did. Any other error prints as Error: {error:?} and returns 1, as the anyhow main did.
  • run destructures the options it takes by value. The signature is the design's, and clippy's needless_pass_by_value refused a body that only borrowed them.
  • The old main body is the private run_args(models, host, args, arguments) -> Result<u8>, with the options' set in place of example_models(). A sweep's Provenance is built there from the host's BuildInfo and the arguments run was given, and explore::run takes it.
  • explore::run, merge_shards and exit_status return u8. median_of lost its pub, since it would otherwise be public API.
  • json_report::info reads engine_version from henad_explore::ENGINE_BUILD.version().
  • The derived #[command(name = "henad-cli", version, about)] is #[command(about)], and the name and version come from the options at run time. clap's string feature is on in henad-cli, for Command::name from a String.
  • The crate doc in lib.rs describes the library, with a no_run example of a host over its own ModelSet, and keeps the ten documented command lines.

Argument parsing, the modes, Reporter, json_report and the text formats stay private.

The binary and the manifest

main.rs is three lines over run, with example_models() and the binary's build_info!(), and wraps the code in ExitCode::from. A binary and a library of one package share the build script's cargo:rustc-env lines, so the stamp build_info!() reads in main.rs is the one M7 set up.

Cargo.toml gains:

  • [lib], and required-features = ["example-models"] on the [[bin]].
  • [features] with default = ["example-models"] and example-models = ["dep:henad-models"].
  • henad-models as an optional normal dependency, and as a dev-dependency for the unit tests, which build example_models() with or without the feature. AGENTS.md names the new dev-dependency edge, as its dependency rule asks.
  • [[test]] entries for golden and manifest with required-features = ["example-models"], since both run the binary through CARGO_BIN_EXE_henad-cli.

Tests

  • Every henad-cli test now runs through the library, and the two integration tests and the golden outputs through the binary and so through run. All pass unchanged apart from the u8 statuses and the provenance argument.
  • existing_invocations_keep_their_mode reads lib.rs, which holds the crate doc, and its floor is 13, the current count of documented lines that are not sweeps: 17 lines, 4 of them sweeps. The new docs section first broke the test twice, once with a sentence opening "henad-cli is a library" and once with a TOML line opening henad-cli = {. The scanner reads both as command lines, and the page now words them otherwise.
  • New: run_returns_the_exit_code_of_each_failure, which runs run over an empty ModelSet and checks 2 for an unknown flag and for --merge without --out, and 1 for a model the set lacks. No other test calls run in process.

Checks

  • cargo build -p henad-cli --no-default-features builds libhenad_cli.rlib and no binary.
  • cargo tree -p henad-cli --no-default-features -e normal -i henad-models prints nothing on stdout. Cargo's "nothing to print" note goes to stderr.
  • cargo clippy -p henad-cli --all-targets with -D warnings -W clippy::all passes with all features and with none.
  • scripts/bench_matrix.py --dry-run --binary printed the same 437 lines through the M8 binary as through the M7 binary.

Equivalence and the instrument gate

The baseline is a release henad-cli built from a git archive of 654539e into a target directory of its own, with the same toolchain.

  • The --json lines of five benchmarks matched the M7 binary's apart from the timing fields and engine_version, which read 0.2.0 on both: SIR at 128² with --seed 42 and outbreaks at ticks 0 and 60, SIR at 128² without --seed, boids at 2,000 agents with and without --seed, and gpu_sir with --seed and --info, whose runtime line matched too.
  • stdout, stderr and the exit code matched byte for byte for --help, --version (henad-cli 0.2.0), --bogus (2), sir --merge x (2), an unknown model (1), --list (0) and sir --set grid_width=0 (1).

The instrument gate ran the M7 binary's benchmark against M8's, both release builds on rustc 1.97.1 (8bab26f4f 2026-07-14), --json --seed 1 with 1000 steps after a 200-step warm-up and five repetitions a run. Each configuration ran ten rounds of M7, M8, M8, M7 after a 120 s idle start and 30 s between configurations, and each M8 run paired repetition by repetition with the M7 run beside it, 100 pairs a configuration. Nothing else was built or run during the timings, and the machine's load average was about 3 from the desktop. No configuration came near the 1000 s cap. The longest, boids at 1,000 agents, took 31 s.

Configuration Repetitions × runs M7 median M8 median Median ratio
game_of_life 64² 5 × 20 1.62 ms 1.63 ms 1.001
game_of_life 1024² 5 × 20 39.7 ms 37.9 ms 0.978
sir 64² 5 × 20 2.04 ms 1.94 ms 0.984
boids 1,000 5 × 20 133 ms 120 ms 0.911, rerun 0.905

Every configuration passes the bound of 1.05. boids at 1,000 agents ran 9% faster under M8 on both runs. The timed loop is run_benchmark in henad-explore in both binaries, and M6 saw the same rung run 14% faster than M5 with no explanation. It is flagged as surprising, not read as a finding. A difference in code layout between the two links is one guess, and nothing here tests it.

Docs

  • reference/cli.md gains Hosting the command line: the official binary included whole from crates/henad-cli/src/main.rs, what run returns, what CliOptions sets, that engine_version keeps reporting Henad's version, the example-models feature with a default-features = false dependency, and that run owns its process. The page's opening links to it.
  • henad-cli's README says the crate is also a library.
  • AGENTS.md: the diagram line, the dependency rule (henad-models behind the feature, and the new dev-dependency edge), and the henad-cli paragraph's opening, device sizing, lookup, host build, engine_version and the invocation test.
  • CHANGELOG: two Added entries (the library, the feature) and two Changed entries (the binary over run, engine_version from ENGINE_BUILD). None is marked "Breaking:". henad-cli had no library and no features before, the binary prints, writes and exits as it did, and engine_version reads the same 0.2.0 for the official binary.

Edited tree

.
├── AGENTS.md                               ~ henad-cli as a library, the feature, the dependency rule
├── CHANGELOG.md                            ~ M8's Added and Changed
├── zensical.toml                           ~ nav entry #37
├── crates/henad-cli/
│   ├── Cargo.toml                          ~ [lib], example-models, required-features, henad-models optional and dev, clap's string
│   ├── README.md                           ~ the library
│   └── src/
│       ├── lib.rs                          ~ moved from main.rs: crate doc, CliOptions, SOME_RUNS_NOT_OK, run, run_args
│       ├── main.rs                         + the three-line official binary
│       ├── explore.rs                      ~ u8 statuses, provenance passed in
│       └── json_report.rs                  ~ engine_version from ENGINE_BUILD
└── docs/
    ├── reference/cli.md                    ~ Hosting the command line
    └── developing/agent-record/20261002-37-library-cli.md   +

State after

M8 is implemented and committed on 48-library on top of 654539e, at the maintainer's request after the hand-over. The workspace version stays 0.2.0.

  • HENAD_REQUIRE_GPU=1 ./check.sh passes: 1040 tests, none failed, with the wasm32 typecheck, the packaging, cargo-deny and docs steps and the web build. M7 passed at 1038. The two new tests are run_returns_the_exit_code_of_each_failure and the crate doc's no_run example.
  • uv run --locked zensical build passes with no issues, and the hosting section renders with main.rs included.
  • schema_hashes_are_unchanged_since_0_2_0 and the golden CLI tests pass unchanged.
  • The gate ran after the code was final, and only docs changed after the M8 binary was built.

Proposed commit message: feat: CLI as a library

Issues found & future directions

  • Deviations from [4.5.2]. CliOptions derives Debug and Clone, which the design leaves open. run returns clap's own code for --help, --version and a usage error, 0 or 2, which the design does not state and the binary returned before. henad-cli enables clap's string feature.
  • The command name sets clap's name, not its binary name. The usage line takes its program name from argv[0], as before, and --version prints the configured name. A Python console script then shows its own script name in the usage line.
  • run installs the panic hook on every call, which replaces a hook the host set. The design's note says run owns the process, and the crate doc and reference/cli.md say so.
  • No CI line builds henad-cli without default features. The two checks above ran by hand. The features job M10b adds is the natural home for both.
  • The worktree. This session ran in .claude/worktrees/m8, a linked worktree on the branch 48-library-m8, since a background job edits outside the shared checkout. The commit was made there, 48-library fast-forwarded to it, and the worktree and its branch were then removed.
  • Next. M9 (the app as a library) depends on M4, M6 and M7 and can start. M10b's facade depends on M8.

Manual notes (human)