ArgMojo v0.5.0 is here with optional struct-based declarative API for argument parsing

Version Mojo pixi User manual

Hey everyone,

ArgMojo just hit v0.5.0 (compatible with Mojo v0.26.2 ) is now available right away via pixi add argmojo. It adds a struct-based declarative API on top of the existing feature-rich builder API. Define a struct, conform to Parsable, and get typed parsing. The declarative layer is entirely optional — the builder API remains the first-class citizen, and the two can be mixed freely (more on that below).

Design Philosophy

The builder API remains the first-class citizen, which is feature-rich, fully functional, and central to the design. The declarative API is optional sugar built on top of it — internally it constructs Command + Argument objects and calls the same parser. If you prefer the builder style, nothing changes for you.

The main inspiration is Apple’s swift-argument-parser. Swift uses property wrappers (@Argument, @Option, @Flag) as metadata on struct fields. Mojo doesn’t have property wrappers, so I used parametric wrapper types instead. Same idea, slightly more explicit.

Four wrapper types, one for each kind of CLI argument (we have one more wrapper, Count, because it is used in builder API):

Wrapper What it is Example field declaration
Positional[T] Bare value, by position var input: Positional[String, required=True]
Option[T] --key value var output: Option[String, long="output"]
Flag Boolean switch var verbose: Flag[short="v"]
Count Repeated counter var debug: Count[short="d", max=3]

Each wrapper accepts the same parameters as the builder methods — choices, default, append, range_min/range_max, group, prompt, password, etc. All compile-time. The Parsable trait auto-initialises all fields via reflection, so you never write __init__.

Example 1: Pure Declarative

Define a struct and call .parse(). That’s it.

from argmojo import Parsable, Option, Flag, Positional, Count

struct Search(Parsable):
    var pattern: Positional[String, help="Search pattern", required=True]
    var path: Positional[String, help="File or directory", default="."]
    var ignore_case: Flag[short="i", help="Case-insensitive"]
    var max_count: Option[Int, short="m", long="max-count", help="Stop after N matches", default="0", has_range=True, range_max=100, clamp=True]
    var verbose: Count[short="v", help="Verbosity", max=3]

    @staticmethod
    def description() -> String:
        return "Search for patterns in files."

def main() raises:
    var args = Search.parse()  # Just one line

    print(args.pattern.value)
    print(args.verbose.value)

Full example: examples/declarative/search.mojo

Example 2: Hybrid — Declarative + Builder

This is where it gets interesting. You define the struct for 80% of arguments, then drop into the builder API for the remaining 20% (groups, implications, help customisation). While some CLI libraries support both styles, ArgMojo enables a seamless round-trip between them — letting you move from struct to Command , customise it, and parse back into a typed struct.

to_command() reflects your struct into an owned Command. You customise it. Then parse_from_command() parses back into your typed struct.

from argmojo import Command, Parsable, Option, Flag, Positional

struct Deploy(Parsable):
    var target: Positional[String, help="Deploy target", required=True, choices="staging,prod"]
    var force: Flag[short="f", help="Force deploy"]
    var dry_run: Flag[long="dry-run", help="Simulate only"]
    var tag: Option[String, long="tag", short="t", help="Release tag"]

    @staticmethod
    def description() -> String:
        return "Deploy to target environment."

def main() raises:
    var cmd = Deploy.to_command()  # Declarative API -> builder API
    cmd.mutually_exclusive(["force", "dry_run"])  # Precise control via builder API
    cmd.add_tip("Use --dry-run to preview changes")
    cmd.header_color["CYAN"]()

    var deploy = Deploy.parse_from_command(cmd^)  # Back to declarative API
    print(deploy.target.value)

Full example: examples/declarative/deploy.mojo

Example 3: Full Parse — Declarative + Extra Builder Args

Sometimes some arguments are too complex for the struct, or you just want to add a few more via the builder. parse_full_from_command() returns both the typed struct and the raw ParseResult, so you can access both worlds.

Again, this seamless round-trip between two is a distinctive feature of ArgMojo.

from argmojo import Command, Argument, Parsable, Positional, Option

struct Convert(Parsable):
    var input: Positional[String, help="Input file", required=True]
    var output: Option[String, long="output", short="o", help="Output"]

    @staticmethod
    def description() -> String:
        return "File format converter."

def main() raises:
    var cmd = Convert.to_command()  # Declarative API -> builder API
    cmd.add_argument(
        Argument("format", help="Output format")
        .long["format"]().short["f"]()
        .choice["json"]().choice["yaml"]()
        .default["json"]()
    )  # Add a complex argument via builder API

    var result = Convert.parse_full_from_command(cmd^)  # Extensive parsing
    ref args = result[0]  # typed Convert struct (declarative API)
    ref raw = result[1]   # raw ParseResult (builder API)

    print(args.input.value)
    print(raw.get_string("format"))

Full example: examples/declarative/convert.mojo

Example 4: Subcommands

Each level in the command tree is a Parsable struct. You can mix declarative and builder subcommands in the same tree. This is probably the most flexible subcommand model I’ve seen in a declarative CLI library — you’re not locked into one paradigm.

from argmojo import Parsable, Option, Flag, Positional, Count, Command

struct Clone(Parsable):
    var url: Positional[String, help="Repository URL", required=True]
    var depth: Option[Int, long="depth", help="Clone depth", default="0"]

    @staticmethod
    def description() -> String: return "Clone a repo."

    @staticmethod
    def name() -> String: return "clone"

    def run(self) raises:
        print("Cloning:", self.url.value)

struct MyGit(Parsable):
    var verbose: Flag[short="v", help="Verbose", persistent=True]

    @staticmethod
    def description() -> String: return "A mini git in Mojo."

    @staticmethod
    def name() -> String: return "mgit"

    @staticmethod
    def subcommands() raises -> List[Command]:
        var subs = List[Command]()
        subs.append(Clone.to_command())
        return subs^

def main() raises:
    var (git, result) = MyGit.parse_full()
    if git.verbose.value:
        print("Verbose mode on")
    if result.subcommand == "clone":
        var sub = result.get_subcommand_result()
        Clone.from_parse_result(sub).run()

For a more complete example that simulates mojo run, mojo build, mojo format, etc. with mixed declarative and builder subcommands, see examples/declarative/jomo.mojo.

API at a Glance

Method Returns What it does
T.parse() T: Parsable One-liner: build + parse argv + typed result
T.to_command() Command Reflect struct into owned Command
T.parse_from_command(cmd^) T: Parsable Parse a customised Command back to typed struct
T.parse_full() Tuple[T: Parsable, ParseResult] Typed struct + raw result
T.parse_full_from_command(cmd^) Tuple[T: Parsable, ParseResult] Same but from customised Command
T.from_parse_result(result) T: Parsable Write-back from ParseResult (for subcommands)

Full changelog is on the releases page.

Thanks to @duck_tape’s mojopt — I learned the mark_initialized trick from their reflection_default function, which lets you default-construct all struct fields via reflection without writing __init__ by hand. The Parsable auto-initialisation in argmojo uses the same approach. Relevant posts: Mojopt: a structopt-like cli option parser.

As a note, @miktavarez prism explores a different direction — a Cobra-style CLI framework with pre/post-run hooks, output redirection, and command lifecycle management, compared to ArgMojo’s focus on typed parsing and composability. Relevant posts: Prism: CLI Library, Prism: A Budding CLI Library!.

Feedback and issues welcome. Thanks!

4 Likes

Note that this isn’t actually default-constructing these values, but rather telling a white lie to the compiler that we’ve initialized them. This can be problematic in the case where one of the struct fields has a non-trivial dtor such as String as it will try and free a pointer that was never allocated properly and crash if an error occurs during parsing before it is properly initialized. I have some logic to guard against this in EmberJson.

Hopefully sometime soon-ish we’ll have compiler generated Defaultable implementations so requiring it will soon be less burdensome [Feature Request] Compiler generated implementation for `Defaultable` · Issue #5828 · modular/modular · GitHub

Thanks for the note, Brian @bgreni. Maybe I should give more elabration on “default-constructing”. Here I mean that this mark_initialized trick can allow me to directly write the default method (__init__ constructor) in the trait definition, instead of leaving it ... in the trait and letting users manually implement it in the struct definition.

It is great point about mark_initialized — it’s a genuinely dangerous API if fields are left as uninitialized garbage. Also, using MLIR in non-stdlib code is not what I want.

Nevertheless, in ArgMojo’s case, the situation seems to be a bit different from EmberJson’s. The __init__ on Parsable does call mark_initialized, but then immediately default-constructs every field in the same comptime loop via UnsafePointer(to=field).init_pointee_move(type_of(field)()). Every field is constrained to Defaultable & Movable (compile-time check via _constrained_field_conforms_to), and the default constructors of the wrapper types (Option, Flag, Positional, Count) don’t raise — they just do self.value = T(). So I think that there’s no window where a field holds garbage and an error could trigger its destructor.

The mark_initialized here is purely to let the compiler accept field references inside the comptime for loop. By the time __init__ returns, every field has been genuinely initialized with a valid default. It’s the same end result as writing out each field assignment by hand, just automated via reflection. Here are the steps:

  1. Step 1: mark_initialized(self): compiler now thinks all fields are valid so we can refelect.
  2. Step 2: init field 0 via type_of(field)()
  3. Step 3: init field 1 via type_of(field)()
  4. Step 4: init field 2 via type_of(field)()
  5. …every field initialized:
    __init__ returns normally. All fields are genuinely valid.

I agree that compiler-generated Defaultable (modular/modular#5828) would make this much cleaner. We could then drop the unsafe trick entirely (safe Mojo again!). Looking forward to that being incorprated into Mojo soon :smiley: