The Go module to build unified multitool CLI applications
  • Go 99.9%
  • Shell 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
surdeus 51ca4e9a2e fix: do not panic when asking an uninitialised Flags where a value came from
WasPassed guards against a Flags whose FlagSet was never set, which is
what a caller building one by hand has. SourceOf called Lookup first,
which dereferences it, so the same value panicked in one method and was
answered in the other.

Guard it the same way, and say in both that the answer is only meaningful
after Parse: a flag cannot be known to have been passed before the flag
package has read the arguments.
2026-10-24 16:15:08 +05:00
cmd/test refactor!: drop the colour handling from mtool 2026-10-24 15:14:00 +05:00
mtool fix: do not panic when asking an uninitialised Flags where a value came from 2026-10-24 16:15:08 +05:00
.gitignore chore: ignore .codewhale session state 2026-10-24 01:06:01 +05:00
btest.sh major: changing path. 2026-03-06 16:49:44 +05:00
go.mod refactor!: drop the colour handling from mtool 2026-10-24 15:14:00 +05:00
go.sum refactor!: drop the colour handling from mtool 2026-10-24 15:14:00 +05:00
readme.md fix: do not panic when asking an uninitialised Flags where a value came from 2026-10-24 16:15:08 +05:00

gocli

A package for command line programs in Go:

  • mtool — a multitool: many programs under one command, like git or bb.

It has no dependencies.

go get codeberg.org/mojosa/gocli

Go 1.18 or later.

mtool

Most of the work happens here. A tree of commands, each with its own flags, descriptions and usage, and a help screen built from them:

package main

import (
	"fmt"
	"os"

	"codeberg.org/mojosa/gocli/mtool"
)

func main() {
	var repeat int

	root := mtool.T("greet").Desc("say hello").Subs(
		mtool.T("hello").Func(func(flags *mtool.Flags) {
			flags.IntVar(&repeat, "n", 1, "how many times", "GREET_N")
			args := flags.Parse()

			for i := 0; i < repeat; i++ {
				fmt.Println("hello", args)
			}
		}).Desc("greet someone").
			Usage("<name>").
			Ldesc("Prints a greeting, once by default."),
	)

	root.Run(os.Args[1:])
}

That gives you the subcommand, the flags, and the help:

$ greet
Name:
  greet - say hello

Usage:
  greet <command>

Commands:
  hello greet someone

$ greet hello -h
Name:
  greet hello - greet someone

Usage:
  greet hello [options] <name>

Description:
  Prints a greeting, once by default.

Options:
  -- option terminator
  -n int
    	how many times
    	($GREET_N) (default 1)

Three things are worth knowing.

Every flag can come from the environment. The names you pass after the usage string are tried in order, and a flag passed on the command line always wins:

flags.IntVar(&repeat, "n", 1, "how many times", "GREET_N", "GREET_COUNT")

A variable holding something the flag cannot take is reported rather than silently ignored, and the flag keeps the value it had.

You can ask where a value came from. flags.WasPassed(name) is true only when the option was given on the command line, which is not the same as the value differing from the default: -n 1 counts as passed even though 1 is also the default. flags.SourceOf(name) answers "flag", "env" or "default" directly.

flags.StringVar(&addr, "addr", "", "address to listen on", "PROG_ADDR")
flags.Parse()

if !flags.WasPassed("addr") && addr == "" {
	addr = "0.0.0.0:8080" // neither the operator nor the environment said
}

That is the difference between "nobody said" and "somebody said empty", which matters when an empty value is a legitimate way to unset something.

Both answer for the parse that has already happened, so ask after Parse. A flag is only known to have been passed once the flag package has read the arguments; before that SourceOf reports the environment or the default.

Failures come back through flags.ParseErr. In the default mode a bad command line ends the process the way the flag package does, since there is no sane way to carry on. A tool that would rather decide for itself asks for the quiet mode:

root := mtool.T("prog").SoftErrors().Subs(/* ... */)

// inside a handler:
args, err := flags.ParseArgs()

The help is plain text. There are no escape sequences anywhere in it, on a terminal or in a pipe, so the output is the same in a log, a pager or a screenshot.