Declarative CLI builder for MoonBit, inspired by gunshi
moon add hustcer/cmderimport {
"hustcer/cmder",
}
pkgtype(kind: "executable")///|
fn main raise {
let app = @cmder.cli(
name="hello",
version="1.0.0",
options=[
@cmder.string("name", short='n', required=true),
@cmder.int("count", short='c', default=1),
@cmder.bool("loud", short='l'),
],
examples=["hello -n Alice", "hello -n Bob -l -c 2"],
run=ctx => {
let name = ctx.get_string_required("name")
let count = ctx.get_int_required("count")
for _ in 0..<count {
let message = "Hello, \{name}!"
println(if ctx.get_bool("loud") { message.to_upper() } else { message })
}
},
)
app.run_or_exit()
}$ hello -n Alice
Hello, Alice!
$ hello -n Bob -l -c 2
HELLO, BOB!
HELLO, BOB!let options = [
@cmder.string("config", short='f', required=true),
@cmder.string("host", default="localhost"),
@cmder.int("port", short='p', default=3000),
@cmder.bool("verbose", short='v', global=true),
]let app = @cmder.cli(
name="copy",
positionals=[
@cmder.positional("source", required=true),
@cmder.positional("destinations"),
],
run=ctx => {
println(ctx.get_string_required("source"))
println(ctx.get_strings("destinations").join(", "))
},
)let app = @cmder.cli(
name="myapp",
options=[@cmder.bool("verbose", short='v', global=true)],
commands=[
@cmder.command(
name="db",
options=[@cmder.string("region", default="west", global=true)],
subcommands=[
@cmder.command(name="migrate", options=[@cmder.bool("dry-run")], run=ctx => {
if ctx.get_bool("verbose") {
println("Starting migration")
}
println(ctx.get_string_required("region"))
println(if ctx.get_bool("dry-run") { "Preview" } else { "Apply" })
}),
],
),
],
)| Method | Result | Meaning |
|---|---|---|
| get_bool(name) | Bool | False when absent |
| get_string(name) | String? | First raw value, including integer spelling |
| get_string_required(name) | String raise CliError | Missing values raise MissingOption / MissingPositional |
| get_int(name) | Int32? raise CliError | None only for absence; format/range errors raise |
| get_int_required(name) | Int32 raise CliError | Missing, malformed and overflow values raise |
| get_strings(name) | Array[String] | Fresh copy of all values; empty when absent |
| get_bool_checked(name) | Bool raise CliError | Declared Boolean flag; false when absent |
| get_string_checked(name) | String? raise CliError | Declared text argument; None when absent |
| get_strings_checked(name) | Array[String] raise CliError | Declared text argument; fresh value copy |
| source_of(name) | ValueSource? | Effective provenance, or None when absent |
let app = @cmder.cli(name="app", options=[@cmder.int("count", default=1)], run=ctx => {
println(ctx.get_int_required("count"))
})
match app.parse(argv=["--count", "2"]) {
Parsed(call) => {
// Perform host checks before execution.
let edited = call.with_overrides({
"count": @cmder.ArgumentValue::Integer(3),
})
edited.dispatch()
}
Help(text) | Version(text) => println(text)
Failed(error) => {
eprintln(error.message())
@env.exit(1)
}
}| Override value | Accepted argument |
|---|---|
| ArgumentValue::Text(text) | String option, or integer option with checked raw syntax |
| ArgumentValue::Integer(value) | Integer option |
| ArgumentValue::Flag(value) | Boolean flag |
| ArgumentValue::Positionals(values) | Positional argument; respects required/final cardinality |
let schema_text = app.render_schema()
let schema_json = app.render_schema_json()
let help = app.render_help(command_path=["db", "migrate"])
let bash_script = app.render_bash_completion()
let zsh_script = app.render_zsh_completion()
let fish_script = app.render_fish_completion()source ./myapp.bashautoload -Uz compinit
compinit
source ./_myappsource ./myapp.fish| Previous usage | Current usage |
|---|---|
| default=Some(3) / default=Some("x") | default=3 / default="x" |
| run=Some(fn(ctx) { ... }) | run=(ctx) => { ... }; errors may propagate |
| app.run(argv=Some(args)) | app.run(argv=args) |
| get_int_checked(name) | get_int(name); malformed input now raises |
| Implicit ancestor parameter inheritance | Mark shared options global=true |
| Direct Context/path mutation | call.with_overrides(...) |
| Context.subcommand / get_subcommand() | Invocation.command_path |
| Public definition record literals | cli, command, string, int, bool, positional helpers |
| app.name, app.commands, app.root_options | app.root.name, app.root.subcommands, app.root.options |
moon check --deny-warn --target native
moon test --target native
moon test --target js
moon test --target wasm-gc
moon run src/examples/cli --target native -- greet -n Alice
moon run src/examples/cli --target native -- db migrate up --dry-run
moon run src/examples/cli --target native -- --schema
nu scripts/check-cli.nu
nu scripts/check-completions.nu
nu scripts/check-readme.nupub(all) suberror CliError {
ParseError(String)
InvalidDefinition(String)
UnknownArgument(String)
ArgumentTypeMismatch(name~ : String, expected~ : String)
UnexpectedLocalArgument(command~ : String, name~ : String)
InvalidOverride(String, String)
MissingCommand
UnknownCommand(Array[String])
MissingSubcommand(String, Array[String])
MissingHandler(String)
MissingOption(String)
MissingPositional(String)
InvalidInteger(String, String)
IntegerOutOfRange(String, String)
} derive(Debug)test {
let error = @cmder.CliError::InvalidInteger("count", "abc")
inspect(error.message(), content="invalid integer for 'count': 'abc'")
}pub(all) enum ArgumentValue {
Text(String)
Integer(Int)
Flag(Bool)
Positionals(ReadOnlyArray[String])
}test {
let app = @cmder.cli(name="app", options=[@cmder.int("count")])
assert_true(
app.parse(argv=["--count", "bad"])
is Failed(@cmder.CliError::InvalidInteger("count", "bad")),
)
}test {
assert_true(
@cmder.cli(name="demo").render_bash_completion().contains("-o default -F"),
)
}test {
assert_true(
@cmder.cli(name="demo")
.render_fish_completion()
.contains("Fish completions"),
)
}test {
assert_true(
@cmder.cli(name="demo").render_zsh_completion().contains("#compdef demo"),
)
}test {
let called = Ref(false)
let app = @cmder.cli(name="demo", run=_ => called.val = true)
app.run(argv=[])
assert_true(called.val)
}test {
let app = @cmder.cli(name="demo", run=_ => ())
app.run_or_exit(argv=[])
}pub struct CommandDef {
name : String
description : String
options : ReadOnlyArray[OptionDef]
positionals : ReadOnlyArray[PositionalDef]
examples : ReadOnlyArray[String]
subcommands : ReadOnlyArray[CommandDef]
run : (Context) -> Unit raise?
required_scope_notice : String?
}pub struct Context {
// private fields
}test {
let app = @cmder.cli(name="app", options=[@cmder.bool("verbose")])
guard app.parse(argv=["--verbose"]) is Parsed(call) else { fail("parse") }
assert_true(call.context.get_bool("verbose"))
}test {
let app = @cmder.cli(name="app", options=[@cmder.bool("loud")])
guard app.parse(argv=[]) is Parsed(call) else { fail("parse") }
assert_false(call.context.get_bool_checked("loud"))
}test {
let app = @cmder.cli(name="app", options=[@cmder.int("count", default=3)])
guard app.parse(argv=[]) is Parsed(call) else { fail("parse") }
assert_true(call.context.get_int("count") == Some(3))
}test {
let app = @cmder.cli(name="app", options=[@cmder.int("count", default=3)])
guard app.parse(argv=[]) is Parsed(call) else { fail("parse") }
inspect(call.context.get_int_required("count"), content="3")
}test {
let app = @cmder.cli(name="app", options=[
@cmder.string("name", default="Ada"),
])
guard app.parse(argv=[]) is Parsed(call) else { fail("parse") }
assert_true(call.context.get_string("name") == Some("Ada"))
}test {
let app = @cmder.cli(name="app", options=[@cmder.string("name")])
guard app.parse(argv=[]) is Parsed(call) else { fail("parse") }
assert_true(call.context.get_string_checked("name") is None)
}test {
let app = @cmder.cli(name="app", options=[
@cmder.string("name", default="Ada"),
])
guard app.parse(argv=[]) is Parsed(call) else { fail("parse") }
inspect(call.context.get_string_required("name"), content="Ada")
}test {
let app = @cmder.cli(name="app", positionals=[@cmder.positional("files")])
guard app.parse(argv=["a", "b"]) is Parsed(call) else { fail("parse") }
inspect(call.context.get_strings("files").length(), content="2")
}test {
let app = @cmder.cli(name="app", positionals=[@cmder.positional("files")])
guard app.parse(argv=["a", "b"]) is Parsed(call) else { fail("parse") }
assert_eq(call.context.get_strings_checked("files"), ["a", "b"])
}test {
let app = @cmder.cli(name="app", options=[@cmder.int("count", default=1)])
guard app.parse(argv=[]) is Parsed(call) else { fail("parse") }
assert_true(
call.context.source_of("count") == Some(@cmder.ValueSource::Default),
)
}test {
let called = Ref(false)
let app = @cmder.cli(name="demo", run=_ => called.val = true)
guard app.parse(argv=[]) is Parsed(call) else { fail("parse") }
assert_false(called.val)
call.dispatch()
assert_true(called.val)
}fn Invocation::with_overrides(self : Invocation, overrides : Map[String, ArgumentValue]) -> Invocation raise CliErrortest {
let app = @cmder.cli(name="app", options=[@cmder.int("count", default=1)])
guard app.parse(argv=[]) is Parsed(call) else { fail("parse") }
let edited = call.with_overrides({ "count": @cmder.ArgumentValue::Integer(3) })
assert_true(edited.context.get_int("count") == Some(3))
assert_true(call.context.get_int("count") == Some(1))
assert_true(edited.source_of("count") == Some(@cmder.ValueSource::Override))
}pub struct OptionDef {
name : String
kind : OptionKind
short : Char?
description : String
required : Bool
global : Bool
} derive(Debug)pub struct PositionalDef {
name : String
description : String
required : Bool
multiple : Bool
}test {
let option = @cmder.bool("verbose", short='v')
inspect(option.name, content="verbose")
}test {
let parser = @cmder.build_command(@cmder.command(name="go"))
assert_true(parser.render_help().has_prefix("Usage: go"))
}fn cli(name~ : String, version? : String, description? : String, options? : ArrayView[OptionDef], positionals? : ArrayView[PositionalDef], examples? : ArrayView[String], commands? : ArrayView[CommandDef], run? : (Context) -> Unit raise, required_scope_notice? : String) -> CliApp raise CliErrortest {
let count = Ref(0)
let app = @cmder.cli(name="demo", options=[@cmder.int("count", default=2)], run=ctx => {
count.val = ctx.get_int_required("count")
})
app.run(argv=[])
assert_eq(count.val, 2)
}fn command(name~ : String, description? : String, options? : ArrayView[OptionDef], positionals? : ArrayView[PositionalDef], examples? : ArrayView[String], subcommands? : ArrayView[CommandDef], run? : (Context) -> Unit raise, required_scope_notice? : String) -> CommandDeftest {
let cmd = @cmder.command(name="go", run=_ => ())
inspect(cmd.name, content="go")
}fn int(name : String, short? : Char, description? : String, required? : Bool, default? : Int, global? : Bool) -> OptionDeftest {
let option = @cmder.int("count", default=3)
inspect(option.required, content="false")
}fn positional(name : String, description? : String, required? : Bool, multiple? : Bool) -> PositionalDeftest {
let file = @cmder.positional("file", required=true, multiple=false)
assert_false(file.multiple)
}