What are the current (late 2024) best practices for CLI development?

Say someone is developing a package, and wants to include some command line interface. What are current best practices / ways to do this? My motivation here is twofold - I am actively working on such a project, and also would like to contribute to Modern Julia Workflows (cf. this issue).

Ideally, this guidance would be

  • Currently supported - that is something can can be used with currently released julia features (eg not relying on juliac / Pkg support)
  • Generic - that is not reliant on specific frameworks (eg Comonicon.jl) or argument parsing libraries (eg ArgParse.jl, Docopt.jl)
  • Have a clear upgrade path for when juliac / App support does arrive.

Some things to consider:

  • Code organization - CLI as a module included within package? Separate package? bin/ directory inside package?
  • Installation - How to make cli calls available to users / accessible via PATH, etc?
  • Precompilation / sysimages - how to reduce the latency for users?
  • Upgrade paths - assuming a user already has an installation, how to replace it / make sure the latest version is installed?

As a provocation to get things going, lets say we want to add a CLI for Example.jl. One way to do this (this is how I’m doing it in my current project) - we can create Example.jl/bin/src/ExampleCLI.jl that contains

module ExampleCLI

export main

using Example

function (@main)(ARGS)
    if first(ARGS) == "hello"
        println(hello(last(ARGS)))
    elseif first(ARGS) == "domath"
        num = parse(Int, last(ARGS))
        println(domath(num))
    else
        throw(ArgumentError("Command $(first(ARGS)) not supported"))
    end
    return 0
end

end

Then create bin/Project.toml with:

name = "ExampleCLI"
uuid = "9a0c4f26-9756-4254-a226-e60a319fd9cd"

[deps]
Example = "7876af07-990d-54b4-ab0e-23690620f79a"

[compat]
Example = "0.5.5"

And run

❯ julia --project=bin -e 'using Pkg; Pkg.instantiate()'

❯ julia --project=bin -e 'using ExampleCLI' domath 32
37

❯ julia --project=bin -e 'using ExampleCLI' hello world
Hello, world

So then, how would we precompile this or make a system image for it? How would we ask users to install it? How would we want to actually run it? Would you organize this differently?

Some related topics:

5 Likes

See also the follow-up: [ANN] YAArguParser and GivEmXL

1 Like

OK, given the lack of responses here, I suppose I will just make stuff up :stuck_out_tongue:

In seriousness, I will take inspiration from the links above, plus two packages with CLIs from @fredrikekre, GitHub - fredrikekre/jlpkg: A command line interface (CLI) for Pkg, Julia's package manager. and GitHub - fredrikekre/Runic.jl: A code formatter for Julia with rules set in stone..

Preserving this from the slack hole, Fredrik said:

The jlpkg Makefile is just for development purposes, there is jlpkg.install (jlpkg/src/jlpkg.jl at 53fa79d1ea1547a9e7b14dded3d5742c43f05f82 · fredrikekre/jlpkg · GitHub) for putting the executable somewhere. It defaults to .julia/bin but nowadays, when most people(?) use juliaup, it might be better to use .juliaup/bin since that should already be in the users PATH.

The Makefile in Runic is just for building the executable with juliac and this is very experimental and doesn’t put the artifact in path. For Runic I tried something different and recommend julia --project=@runic -m Runic instead, possibly with a shell alias, see GitHub - fredrikekre/Runic.jl: A code formatter for Julia with rules set in stone.

@davidanthoff do you have any thoughts about things going in ~/.julia/juliaup/bin? @tecosaur I know you had thoughts expressed in one of those long threads or issues. I’m sure I’ll find it again, but if you want to reiterate here I wouldn’t mind :wink:

1 Like

Oh, I’ve just got my usual thoughts about it being nice to take advantage of user bin directories already on $PATH before creating new directories. I have an “app” package that does this by symlinking an executable artifact to BaseDirs.User.bin("juliaclient"):

This isn’t a Julia-written executable, but I feel like this is somewhat general. Unfortunately, platforms are varying degrees of annoying to support with a BaseDirs.User.bin("juliaclient")-like form.

  • Linux: excellent, .local/bin is part of multiple standards (XDG Base Directories, Systemd file-hierarchy spec, etc.)
  • MacOS: Dodgy, there’s no official option, but there are a few common choices for people who create a directory for this purpose (e.g. ~/bin)
  • Windows: Similar/worse than Mac, a few common choices but no standard (despite the massive number of known folders…)

David would know more, but I believe Windows does have good support for adding something to the path with an installer?