Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

std/cli/argparse

Declarative, schema-aware argument parsing for .harn CLI subcommand scripts dispatched via the harn-cli wedge (harn#2293 epic, harn#2295). Each subcommand declares a parser spec and parses the global argv installed by the dispatch wedge.

The library is pure .harn and covers:

  • Required and optional positional arguments, including one variadic positional.
  • Long flags (--model, --model=val, --model val) and short flags (-m val, -mval).
  • Boolean switches (--json).
  • Repeated flags collected with multi: true.
  • Primitive string, int, float, bool, and list decoding.
  • A bare -- terminator that routes following tokens to a separate rest list.
  • Optional schema validation and narrowing through parse_typed<T>.

Nested subcommand parsers and shell completion generation remain out of scope. Top-level clap dispatch already owns those concerns.

API

pub fn parser(spec: ParserSpec) -> ParserSpec

pub fn parse(
  spec: ParserSpec,
  argv: list<string>,
) -> Result<CliInvocation<dict>, CliParseFailure>

pub fn parse_typed<T>(
  spec: ParserSpec,
  argv: list<string>,
  schema: Schema<T>,
  apply_defaults: bool = false,
) -> Result<CliInvocation<T>, CliParseFailure>

pub fn help_requested(argv: list<string>) -> bool

pub fn render_help(spec: ParserSpec) -> string

CliInvocation<T> has two fields:

{options: T, rest: list<string>}

Declared arguments are under .options. When no flag value is pending, a bare -- stops argument parsing; later tokens are placed under .rest and are never mixed into the option bag or passed to the schema. Required argument checks still run.

Handle canonical help before parsing, then use native Result helpers rather than inspecting an object envelope:

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

const spec = parser({
  name: "render",
  args: [{name: "input", kind: "positional"}],
})
if help_requested(argv) {
  __io_println(render_help(spec))
  exit(0)
}
const result = parse(spec, argv)
if is_err(result) {
  const failure = unwrap_err(result)
  __io_eprintln(render_help(spec))
  __io_eprintln("error: " + failure.message)
  exit(2)
}
const invocation = unwrap(result)
const input = invocation.options.input
const forwarded = invocation.rest

Parser spec

ParserSpec is:

{
  name: string,
  about?: string,
  args: list<ArgSpec>,
  examples?: list<string>,
}

Each ArgSpec supports:

FieldMeaning
nameNon-empty, unique key written to .options.
kind"positional", "flag", or "switch".
short, longFlag/switch aliases such as "-m" and "--model". At least one is required for flags and switches.
requiredPositionals default to true; flags and switches default to false.
multiFor flags, collect repeated occurrences into one list.
variadicFor a positional, greedily collect remaining positional tokens.
value_namePlaceholder used by help output; defaults to the uppercased name.
helpOne-line help text.
parse"string" (default), "int", "float", "bool", or "list".
separatorDelimiter for parse: "list"; defaults to ",".
defaultAlready-typed, non-nil value inserted unchanged when an optional argument is absent. It does not satisfy required: true.

A switch never consumes a value: its presence produces true, and its implicit default is false. A multi flag implicitly defaults to []. Other omitted optional values have no implicit entry unless default is declared.

Defaults are not strings waiting to be decoded. For example, an integer flag uses default: 4, not default: "4", and a list flag uses a list value. parser() rejects defaults that cannot satisfy the declared decoder, kind, and multiplicity, so an invalid static declaration fails before argv is read. Required presence is checked first: a required flag or switch must appear in argv even if its spec declares a default. Explicit defaults, the switch false default, and the multi [] default are then applied to omitted optional arguments.

Positionals bind from left to right. A required positional cannot follow an optional positional because that declaration has no unambiguous binding rule; parser() rejects it. A variadic positional must remain last.

Primitive decoding

parse decodes argv strings at the argument boundary:

DecoderAccepted value and output
stringThe original argv string.
intA base-10 integer with an optional leading sign.
floatA signed or unsigned decimal, optionally with an exponent.
boolCase-insensitive true, yes, y, 1, on, or false, no, n, 0, off, after trimming.
listlist<string> split on separator, or on , when omitted.

Invalid int, float, or bool text returns an argv-stage invalid_value failure. Switches may omit parse, declare parse: "string", or declare parse: "bool"; because they do not consume values, all three forms still produce a boolean.

A signed numeric token such as -7 or -0.25 binds to the next int or float positional when that decoder accepts it. Registered short aliases are still resolved as flags, and other dash-prefixed tokens remain unknown_flag failures.

With both multi: true and parse: "list", each occurrence is split and the results are flattened into one list. For example:

{name: "tag", kind: "flag", long: "--tag", multi: true,
 parse: "list", separator: ":"}

["--tag", "core:cli", "--tag=docs:tests"] produces ["core", "cli", "docs", "tests"], not a nested list.

Typed parsing

parse_typed<T> first performs the same argv parsing and primitive decoding as parse, then validates only the resulting .options against the supplied Schema<T>. On success, .options is narrowed to T while .rest remains list<string>.

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

type RunOptions = {
  input: string,
  jobs: int,
  tags: list<string>,
  verbose: bool,
}

const spec = parser({
  name: "run",
  args: [
    {name: "input", kind: "positional"},
    {name: "jobs", kind: "flag", long: "--jobs", parse: "int", default: 1},
    {name: "tags", kind: "flag", long: "--tags", parse: "list", multi: true},
    {name: "verbose", kind: "switch", long: "--verbose", parse: "bool"},
  ],
})

const result = parse_typed(spec, argv, schema_of(RunOptions))
if is_err(result) {
  __io_eprintln(unwrap_err(result).message)
  exit(2)
}
const invocation = unwrap(result)
const jobs: int = invocation.options.jobs

The fourth argument controls schema-owned defaults. It defaults to false, which checks without applying schema defaults. Pass true to apply defaults declared by the schema. ArgSpec.default values are always applied by argv parsing first and are already typed; this flag does not control them.

Failures

parse and parse_typed return CliParseFailure in Result.Err:

{
  kind: "cli_parse_failure",
  stage: "argv" | "schema",
  code: string,
  message: string,
  issues: list<{
    stage: "argv" | "schema",
    code: string,
    message: string,
    arg?: string,
    path?: string,
  }>,
}

The failure and every issue contain only JSON-safe data.

Argv stage

An argv-stage failure has the same code at the top level and in its one issue. The issue's arg identifies the argument name, flag, or token associated with the failure.

codeWhen
missing_requiredA required positional or flag is absent after defaults are applied.
unknown_flagA long or short flag is not registered in the spec.
unknown_argA positional appears after all declared positionals are filled.
value_requiredA value-taking flag is the last argv entry.
bad_valueA switch is supplied with an inline value.
invalid_valuePrimitive int, float, or bool decoding fails.

Schema stage

When argv parsing succeeds but schema validation fails, the top-level failure has stage: "schema" and code: "schema_mismatch". Each schema report issue is projected into issues with stage: "schema", its schema validation code and message, and its path (for example, jobs). If the schema report omits issues or paths, argparse supplies one issue and uses root as the fallback path.

This stage distinction lets command code render one stable failure shape without re-checking or re-validating .options at the call site.

Static parser errors

parser(spec) checks static programmer-owned declarations and throws immediately when a spec is invalid. These exceptions are not CliParseFailure values. It rejects:

  • Empty argument names, duplicate names, invalid kinds, and duplicate flag aliases.
  • Positionals with short, long, or multi, and any positional after a variadic positional.
  • Flags or switches without a short or long alias, or with variadic: true.
  • Switches with multi: true, or with a decoder other than the default string or bool.
  • Unknown primitive decoders.
  • separator without parse: "list", or an empty list separator.

--help rendering

render_help(spec) returns a stable, snapshot-tested layout:

{about, if present}

USAGE:
  {name} [OPTIONS] <positional>...

ARGS:
  <positional>                  help text

OPTIONS:
  -s, --long <VALUE>            help text
      --switch                  help text
  -h, --help                    Print help

EXAMPLES:
  {example 1}
  {example 2}

The layout is clap-flavored without trying to match it byte-for-byte. Snapshot tests under conformance/tests/cli/ pin its structure and column alignment.