How to define subcommands for a `@main` command in a Julia app?

Since Julia now allows us to install an app/command to call it from the Shell by the @main function, I wonder how to mimic cases like some commands having subcommands in @main? Like git add, git status, git clone for git. That is, those subcommands should be gathered under a global name.

A possible solution I can think of is the check the second argument of ARGS, e.g., if it is a specific subcommand. I am not sure this is the correct way.

I was using Comonicon.jl, which allows subcommands as long as they are defined in submodules of a package.

1 Like

I am not sure if I understand what you mean with subcommands but DocOpt.jl implements the infamous docopt concept (awesome talk by Vladimir Keleshev btw. https://youtu.be/pXhcPJK5cMc) and you can define a command line interface (CLI) pretty naturally: GitHub - docopt/DocOpt.jl: command line arguments parser

One of the first slides shows this CLI

and I assume the “subcommand” you are referring to would be something like tcp or serial in that example.

You would then need to parse the arguments and call the appropriate function.

If you however mean something like dedicated functions for different “subcommands”, like e.g. what Click for Python implements, I think there is nothing comparable (yet) in the Julia ecosystem. Note that Click provides type checking and direct mapping of functions to the CLI. Might be an overkill in some cases :wink:

The subcommand should be the first argument not the second argument, since the main script name does not appear at the beginning of ARGS.

Then you can ask the function corresponding to the subcommand to parse ARGS[2:end]. For example, ArgParse.jl can parse a user-supplied argument list.

Thanks. I mean something like docopt, but this was already implemented in Comonicon.jl. I was wondering if I can do that directly with ARGS.

No, ARGS is just a lightweight dictionary :wink:

Of course, the package you mentioned is essentially a nice package for parsing ARGS. You could cook up your own version if you want.

Maybe this example can help you to start with:

module SimpleCalcProject

using TOML

const version = TOML.parsefile("Project.toml")["version"]

function print_help()
    println(Core.stdout,
        """
SimpleCalcProject CLI
Usage:
  simplecalc <command> [args...]

Commands:
  add <a> <b>        Add two numbers
  sub <a> <b>        Subtract second number from first
  sum <nums...>      Sum all numbers (sum)

Options:
  -h, --help         Show this help message
  -v, --version      Show version information
""")
end

function print_version()
    println(Core.stdout, "SimpleCalcProject CLI v$(version)")
end

function parse_numbers(remaining)
    try
        tryparse.(Float64, remaining)::Vector{Float64}
    catch 
        println(Core.stdout, "Error: invalid argument(s)")
        Float64[]
    end
end

function print_calculation(cmd, numbers)
    if cmd == "add" && length(numbers) == 2
        println(Core.stdout, "Result: $(numbers[1] + numbers[2])")
    elseif cmd == "sub" && length(numbers) == 2
        println(Core.stdout, "Result: $(numbers[1] - numbers[2])")
    elseif cmd == "sum" && !isempty(numbers)
        println(Core.stdout, "Result: $(sum(numbers))")
    else
        println(Core.stdout, "Invalid command or arguments. Try --help.")
    end
end

function cli(args)

    if isempty(args)
        print_help()
        return 0
    end

    cmd = args[1]

    if cmd in ["--help", "-h"]
        print_help()
        return 0
    elseif cmd in ["--version", "-v"]
        print_version()
        return 0
    end
    
    remaining = args[2:end]
    
    numbers = parse_numbers(remaining)
    
    length(numbers) == 0 && return 1
    
    print_calculation(cmd, numbers)
    
    return 0
end

function @main(ARGS)
    return cli(ARGS)
end

end