Native CLI Quickstart#

This quickstart shows a simple but proper MoonBit CLI layout:

  • keep argument parsing and business logic in a library package

  • keep cmd/main small

  • use moonbitlang/async for native IO

  • test the pure parts without touching the network

This example uses moonbitlang/async, which currently supports the native backend best.

Create the project#

Start with a normal MoonBit module:

moon new download_cli
cd download_cli
moon add moonbitlang/async@0.19.2

argparse is already part of the standard library, so this quickstart only adds moonbitlang/async.

Set the preferred target to native in moon.mod.json so moon run and moon build default to the backend that moonbitlang/async supports best:

{
  "name": "username/download_cli",
  "version": "0.1.0",
  "deps": {
    "moonbitlang/async": "0.19.2"
  },
  "preferred-target": "native"
}

The final layout will look like this:

download_cli
├── cmd
│   └── main
│       ├── main.mbt
│       └── moon.pkg
├── cli_test.mbt
├── config.mbt
├── download.mbt
├── moon.mod.json
└── moon.pkg

Keep parsing in the library package#

The root package defines the CLI contract. It owns the command shape and turns argv into a typed config value:

pub struct  {
   : 
   : ?
} derive()

///|
pub fn () ->  {
  (
    "moon-fetch",
    ="Download a URL to stdout or a file",
    =[
      (
        "output",
        ='o',
        ="Write the response body to this file",
      ),
    ],
    =[
      (
        "url",
        ="HTTP or HTTPS URL to download",
        =::(),
      ),
    ],
  )
}

///|
pub fn ( : []) ->  raise {
  let  = ((), )
  let  : [, []] = .
  guard  is { "url": [], "output"? : , .. } else {
    ("missing url")
  }
  let  = match  {
    ([, ..]) => ()
    _ => 
  }
  { ,  }
}

The package descriptor imports argparse from moonbitlang/core and the async libraries used by the implementation:

import {
  "moonbitlang/core/argparse",
  "moonbitlang/core/test",
  "moonbitlang/async/fs",
  "moonbitlang/async/http",
  "moonbitlang/async/stdio",
}

Put async IO behind one function#

run performs the actual download. If -o is passed, it streams the body into a file. Otherwise it writes directly to stdout:

pub async fn ( : ) ->  {
  let (, ) = (.)
  defer .()

  guard . is (200..<300) else {
    ("download failed: \{response.code} \{response.reason}")
  }

  match . {
    () => {
      let  = (, =0o644)
      defer .()
      .()
      .("saved \{config.url} to \{path}\n")
    }
     => .()
  }
}

Keep main thin#

cmd/main should usually do only wiring: read argv, build config, and call the library entrypoint.

import {
  "moonbit-community/cli-quickstart-doc" @app,
  "moonbitlang/core/env",
  "moonbitlang/async",
}

options(
  "is-main": true,
)
async fn main {
  let  = ()
  let  = ([1:])
  ()
}

Run the command#

Write the response body to stdout:

moon run cmd/main https://example.com/feed.xml

Write it to a file:

moon run cmd/main https://example.com/feed.xml -o feed.xml

Build a native binary:

moon build --target native

Test the pure part#

The parser and config shaping logic stay easy to test because they do not perform IO:

///|
test "parse config for stdout" {
  let  = (["https://example.com/feed.xml"])
  (., "https://example.com/feed.xml")
  (., )
}

///|
test "parse config for file output" {
  let  = (["https://example.com/feed.xml", "-o", "feed.xml"])
  (., "https://example.com/feed.xml")
  guard . is () else { ("expected an output path") }
  (, "feed.xml")
}

Run the tests with:

moon test

When the CLI grows, keep following the same split:

  • parse and validate inputs in the library package

  • keep side effects in a small number of async functions

  • keep cmd/main focused on wiring