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!