warg

package module
v0.0.28 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 8, 2025 License: MIT Imports: 21 Imported by: 6

README

warg

An opinionated CLI framework:

  • declarative nested commands
  • Detailed --help output (including what a flag is currently set to)
  • update flags from os.Args, config files, environment variables, and app defaults
  • extend with new flag types, config file formats, or --help output
  • snapshot testing support

Project Status (2025-01-24)

The "bones" of warg are where I want them, but I have several breaking changes I'd like to try before considering warg stable. None of them will change the basic way warg works, and I haven't had a lot of trouble updating my apps using warg. See the CHANGELOG for past breaking changes.

Examples

All of the CLIs on my profile use warg.

See API docs (including code examples) at pkg.go.dev

Simple "butler" example (full source here):

app := warg.New(
  "butler",
  "v1.0.0",
  section.New(
    section.HelpShort("A virtual assistant"),
    section.NewCommand(
      "present",
      "Formally present a guest (guests are never introduced, always presented).",
      present,
      command.NewFlag(
        "--name",
        "Guest to address.",
        scalar.String(),
        flag.Alias("-n"),
        flag.EnvVars("BUTLER_PRESENT_NAME", "USER"),
        flag.Required(),
      ),
    ),
    section.CommandMap(warg.VersionCommandMap()),
  ),
  warg.GlobalFlagMap(warg.ColorFlagMap()),
)

Butler help screenshot

When to avoid warg

By design, warg apps have the following requirements:

  • must contain at least one subcommand. This makes it easy to add further subcommands, such as a version subcommand. It is not possible to design a warg app such that calling <appname> --flag <value> does useful work. Instead, <appname> <command> --flag <value> must be used.
  • warg does not support positional arguments. Instead, use a required flag: git clone <url> would be git clone --url <url>. This makes parsing much easier, and I like the simplicity of it.

Alternatives

  • cobra is by far the most popular CLI framework for Go.
  • cli is also very popular.
  • I haven't tried ff, but it looks similar to warg, though maybe less batteries-included
  • I've used the now unmaintained kingpin fairly successfully.

Documentation

Overview

Declaratively create heirarchical command line apps.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func ColorFlagMap added in v0.0.28

func ColorFlagMap() flag.FlagMap

ColorFlagMap returns a map with a single "--color" flag that can be used to control color output.

Example:

warg.GlobalFlagMap(warg.ColorFlagMap())

func GoldenTest added in v0.0.18

func GoldenTest(
	t *testing.T,
	args GoldenTestArgs,
	parseOpts ...ParseOpt)

GoldenTest runs the app and and captures stdout and stderr into files. If those differ than previously captured stdout/stderr, t.Fatalf will be called.

Passed `parseOpts` should not include OverrideStderr/OverrideStdout as GoldenTest overwrites those

func VersionCommandMap added in v0.0.28

func VersionCommandMap() command.CommandMap

VersioncommandMap returns a map with a single "version" command that prints the app version.

Example:

warg.GlobalFlagMap(warg.ColorFlagMap())

Types

type App

type App struct {
	// contains filtered or unexported fields
}

An App contains your defined sections, commands, and flags Create a new App with New()

func New

func New(name string, version string, rootSection section.SectionT, opts ...AppOpt) App

New creates a warg app. name is used for help output only (though generally it should match the name of the compiled binary). version is the app version - if empty, warg will attempt to set it to the go module version, or "unknown" if that fails.

Example
package main

import (
	"fmt"
	"os"

	"go.bbkane.com/warg"
	"go.bbkane.com/warg/command"
	"go.bbkane.com/warg/flag"
	"go.bbkane.com/warg/section"
	"go.bbkane.com/warg/value/scalar"
)

func login(ctx command.Context) error {
	url := ctx.Flags["--url"].(string)

	// timeout doesn't have a default value,
	// so we can't rely on it being passed.
	timeout, exists := ctx.Flags["--timeout"]
	if exists {
		timeout := timeout.(int)
		fmt.Printf("Logging into %s with timeout %d\n", url, timeout)
		return nil
	}

	fmt.Printf("Logging into %s\n", url)
	return nil
}

func main() {
	commonFlags := flag.FlagMap{
		"--timeout": flag.New(
			"Optional timeout. Defaults to no timeout",
			scalar.Int(),
		),
		"--url": flag.New(
			"URL of the blog",
			scalar.String(
				scalar.Default("https://www.myblog.com"),
			),
			flag.EnvVars("BLOG_URL"),
		),
	}
	app := warg.New(
		"newAppName",
		"v1.0.0",
		section.New(
			"work with a fictional blog platform",
			section.NewCommand(
				"login",
				"Login to the platform",
				login,
				command.FlagMap(commonFlags),
			),
			section.NewSection(
				"comments",
				"Deal with comments",
				section.NewCommand(
					"list",
					"List all comments",
					// still prototyping how we want this
					// command to look,
					// so use a provided stub action
					command.DoNothing,
					command.FlagMap(commonFlags),
				),
			),
		),
	)

	// normally we would rely on the user to set the environment variable,
	// bu this is an example
	err := os.Setenv("BLOG_URL", "https://envvar.com")
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	app.MustRun(warg.OverrideArgs([]string{"blog.exe", "login"}))
}
Output:

Logging into https://envvar.com

func (*App) MustRun

func (app *App) MustRun(opts ...ParseOpt)

MustRun runs the app. Any flag parsing errors will be printed to stderr and os.Exit(64) (EX_USAGE) will be called. Any errors on an Action will be printed to stderr and os.Exit(1) will be called.

func (*App) Parse

func (app *App) Parse(opts ...ParseOpt) (*ParseResult, error)

Parse parses the args, but does not execute anything.

Example (Flag_value_options)

ExampleApp_Parse_flag_value_options shows a couple combinations of flag/value options. It's also possible to use '--help detailed' to see the current value of a flag and what set it.

package main

import (
	"fmt"
	"log"
	"os"

	"go.bbkane.com/warg"
	"go.bbkane.com/warg/command"
	"go.bbkane.com/warg/config/yamlreader"
	"go.bbkane.com/warg/flag"
	"go.bbkane.com/warg/path"
	"go.bbkane.com/warg/section"
	"go.bbkane.com/warg/value/scalar"
	"go.bbkane.com/warg/value/slice"
)

func main() {

	action := func(ctx command.Context) error {
		// flag marked as Required(), so no need to check for existance
		scalarVal := ctx.Flags["--scalar-flag"].(string)
		// flag might not exist in config, so check for existance
		// TODO: does this panic on nil?
		sliceVal, sliceValExists := ctx.Flags["--slice-flag"].([]int)

		fmt.Printf("--scalar-flag: %#v\n", scalarVal)
		if sliceValExists {
			fmt.Printf("--slice-flag: %#v\n", sliceVal)
		} else {
			fmt.Printf("--slice-flag value not filled!\n")
		}
		return nil
	}

	app := warg.New(
		"flag-overrides",
		"v1.0.0",
		section.New(
			"demo flag overrides",
			section.NewCommand(
				command.Name("show"),
				"Show final flag values",
				action,
				command.NewFlag(
					"--scalar-flag",
					"Demo scalar flag",
					scalar.String(
						scalar.Choices("a", "b"),
						scalar.Default("a"),
					),
					flag.ConfigPath("args.scalar-flag"),
					flag.Required(),
				),
				command.NewFlag(
					"--slice-flag",
					"Demo slice flag",
					slice.Int(
						slice.Choices(1, 2, 3),
					),
					flag.Alias("-slice"),
					flag.ConfigPath("args.slice-flag"),
					flag.EnvVars("SLICE", "SLICE_ARG"),
				),
			),
		),
		warg.ConfigFlag(
			"--config",
			[]scalar.ScalarOpt[path.Path]{
				scalar.Default(path.New("~/.config/flag-overrides.yaml")),
			},
			yamlreader.New,
			"path to YAML config file",
			flag.Alias("-c"),
		),
	)

	err := os.WriteFile(
		"testdata/ExampleFlagValueOptions/config.yaml",
		[]byte(`args:
  slice-flag:
    - 1
    - 2
    - 3
`),
		0644,
	)
	if err != nil {
		log.Fatalf("write error: %e", err)
	}
	app.MustRun(
		warg.OverrideArgs([]string{"calc", "show", "-c", "testdata/ExampleFlagValueOptions/config.yaml", "--scalar-flag", "b"}),
	)
}
Output:

--scalar-flag: "b"
--slice-flag: []int{1, 2, 3}

func (*App) Parse2 added in v0.0.26

func (a *App) Parse2(args []string, lookupEnv LookupFunc) (*ParseResult2, error)

func (*App) Validate added in v0.0.13

func (app *App) Validate() error

Validate checks app for creation errors. It checks:

- Sections and commands don't start with "-" (needed for parsing)

- Flag names and aliases do start with "-" (needed for parsing)

- Flag names and aliases don't collide

type AppOpt

type AppOpt func(*App)

AppOpt let's you customize the app. Most AppOpts panic if incorrectly called

func ConfigFlag

func ConfigFlag(

	configFlagName flag.Name,

	scalarOpts []scalar.ScalarOpt[path.Path],
	newConfigReader config.NewReader,
	helpShort flag.HelpShort,
	flagOpts ...flag.FlagOpt,
) AppOpt

Use ConfigFlag in conjunction with flag.ConfigPath to allow users to override flag defaults with values from a config. This flag will be parsed and any resulting config will be read before other flag value sources.

Example
package main

import (
	"fmt"
	"log"
	"os"

	"go.bbkane.com/warg"
	"go.bbkane.com/warg/command"
	"go.bbkane.com/warg/config/yamlreader"
	"go.bbkane.com/warg/flag"
	"go.bbkane.com/warg/path"
	"go.bbkane.com/warg/section"
	"go.bbkane.com/warg/value/scalar"
	"go.bbkane.com/warg/value/slice"
)

func exampleConfigFlagTextAdd(ctx command.Context) error {
	addends := ctx.Flags["--addend"].([]int)
	sum := 0
	for _, a := range addends {
		sum += a
	}
	fmt.Printf("Sum: %d\n", sum)
	return nil
}

func main() {
	app := warg.New(
		"newAppName",
		"v1.0.0",
		section.New(
			"do math",
			section.NewCommand(
				command.Name("add"),
				"add integers",
				exampleConfigFlagTextAdd,
				command.NewFlag(
					flag.Name("--addend"),
					"Integer to add. Flag is repeatible",
					slice.Int(),
					flag.ConfigPath("add.addends"),
					flag.Required(),
				),
			),
		),
		warg.ConfigFlag(
			"--config",
			[]scalar.ScalarOpt[path.Path]{
				scalar.Default(path.New("~/.config/calc.yaml")),
			},
			yamlreader.New,
			"path to YAML config file",
			flag.Alias("-c"),
		),
	)

	err := os.WriteFile(
		"testdata/ExampleConfigFlag/calc.yaml",
		[]byte(`add:
  addends:
    - 1
    - 2
    - 3
`),
		0644,
	)
	if err != nil {
		log.Fatalf("write error: %e", err)
	}
	app.MustRun(
		warg.OverrideArgs([]string{"calc", "add", "-c", "testdata/ExampleConfigFlag/calc.yaml"}),
	)
}
Output:

Sum: 6

func GlobalFlag added in v0.0.24

func GlobalFlag(name flag.Name, value flag.Flag) AppOpt

GlobalFlag adds an existing flag to a Command. It panics if a flag with the same name exists

func GlobalFlagMap added in v0.0.28

func GlobalFlagMap(flagMap flag.FlagMap) AppOpt

GlobalFlagMap adds existing flags to a Command. It panics if a flag with the same name exists

func NewGlobalFlag added in v0.0.28

func NewGlobalFlag(name flag.Name, helpShort flag.HelpShort, empty value.EmptyConstructor, opts ...flag.FlagOpt) AppOpt

NewGlobalFlag adds a flag to the app. It panics if a flag with the same name exists

func OverrideHelpFlag

func OverrideHelpFlag(
	mappings []help.HelpFlagMapping,
	defaultChoice string,
	flagName flag.Name,
	flagHelp flag.HelpShort,
	flagOpts ...flag.FlagOpt,
) AppOpt

OverrideHelpFlag customizes your --help. If you write a custom --help function, you'll want to add it to your app here!

Example
package main

import (
	"fmt"

	"go.bbkane.com/warg"
	"go.bbkane.com/warg/command"
	"go.bbkane.com/warg/flag"
	"go.bbkane.com/warg/help"
	"go.bbkane.com/warg/help/common"
	"go.bbkane.com/warg/help/detailed"
	"go.bbkane.com/warg/section"
)

func exampleOverrideHelpFlaglogin(_ command.Context) error {
	fmt.Println("Logging in")
	return nil
}

func exampleOverrideHelpFlagCustomCommandHelp(_ *command.Command, _ common.HelpInfo) command.Action {
	return func(ctx command.Context) error {
		file := ctx.Stdout
		fmt.Fprintln(file, "Custom command help")
		return nil
	}
}

func exampleOverrideHelpFlagCustomSectionHelp(_ *section.SectionT, _ common.HelpInfo) command.Action {
	return func(ctx command.Context) error {
		file := ctx.Stdout
		fmt.Fprintln(file, "Custom section help")
		return nil
	}
}

func main() {
	app := warg.New(
		"newAppName",
		"v1.0.0",
		section.New(
			"work with a fictional blog platform",
			section.NewCommand(
				"login",
				"Login to the platform",
				exampleOverrideHelpFlaglogin,
			),
		),
		warg.OverrideHelpFlag(
			[]help.HelpFlagMapping{
				{
					Name:        "default",
					CommandHelp: detailed.DetailedCommandHelp,
					SectionHelp: detailed.DetailedSectionHelp,
				},
				{
					Name:        "custom",
					CommandHelp: exampleOverrideHelpFlagCustomCommandHelp,
					SectionHelp: exampleOverrideHelpFlagCustomSectionHelp,
				},
			},
			"default",
			"--help",
			"Print help",
			flag.Alias("-h"),
			// the flag default should match a name in the HelpFlagMapping
		),
	)

	app.MustRun(warg.OverrideArgs([]string{"blog.exe", "-h", "custom"}))
}
Output:

Custom section help

func SkipValidation added in v0.0.13

func SkipValidation() AppOpt

SkipValidation skips (most of) the app's internal consistency checks when the app is created. If used, make sure to call app.Validate() in a test!

type FlagValue added in v0.0.26

type FlagValue struct {
	SetBy string
	Value value.Value
}

type FlagValueMap added in v0.0.26

type FlagValueMap map[flag.Name]value.Value

func (FlagValueMap) ToPassedFlags added in v0.0.26

func (m FlagValueMap) ToPassedFlags() command.PassedFlags

type GoldenTestArgs added in v0.0.23

type GoldenTestArgs struct {
	App *App

	// UpdateGolden files for captured stderr/stdout
	UpdateGolden bool

	// Whether the action should return an error
	ExpectActionErr bool
}

type LookupFunc

type LookupFunc func(key string) (string, bool)

Look up keys (meant for environment variable parsing) - fulfillable with os.LookupEnv or warg.LookupMap(map)

func LookupMap

func LookupMap(m map[string]string) LookupFunc

LookupMap loooks up keys from a provided map. Useful to mock os.LookupEnv when parsing

type ParseOpt added in v0.0.21

type ParseOpt func(*ParseOptHolder)

func AddContext added in v0.0.21

func AddContext(ctx context.Context) ParseOpt

func OverrideArgs added in v0.0.21

func OverrideArgs(args []string) ParseOpt

func OverrideLookupFunc added in v0.0.21

func OverrideLookupFunc(lookup LookupFunc) ParseOpt

func OverrideStderr added in v0.0.18

func OverrideStderr(stderr *os.File) ParseOpt

func OverrideStdout added in v0.0.18

func OverrideStdout(stdout *os.File) ParseOpt

type ParseOptHolder added in v0.0.21

type ParseOptHolder struct {
	Args []string

	Context context.Context

	LookupFunc LookupFunc

	// Stderr will be passed to command.Context for user commands to print to.
	// This file is never closed by warg, so if setting to something other than stderr/stdout,
	// remember to close the file after running the command.
	// Useful for saving output for tests. Defaults to os.Stderr if not passed
	Stderr *os.File

	// Stdout will be passed to command.Context for user commands to print to.
	// This file is never closed by warg, so if setting to something other than stderr/stdout,
	// remember to close the file after running the command.
	// Useful for saving output for tests. Defaults to os.Stdout if not passed
	Stdout *os.File
}

func NewParseOptHolder added in v0.0.21

func NewParseOptHolder(opts ...ParseOpt) ParseOptHolder

type ParseResult

type ParseResult struct {
	Context command.Context
	// Action holds the passed command's action to execute.
	Action command.Action
}

ParseResult holds the result of parsing the command line.

type ParseResult2 added in v0.0.26

type ParseResult2 struct {
	SectionPath    []string
	CurrentSection *section.SectionT

	CurrentCommandName command.Name
	CurrentCommand     *command.Command

	CurrentFlagName flag.Name
	CurrentFlag     *flag.Flag
	FlagValues      FlagValueMap
	UnsetFlagNames  UnsetFlagNameSet

	HelpPassed bool
	State      ParseState
}

type ParseState added in v0.0.26

type ParseState string
const (
	Parse_ExpectingSectionOrCommand ParseState = "Parse_ExpectingSectionOrCommand"
	Parse_ExpectingFlagNameOrEnd    ParseState = "Parse_ExpectingFlagNameOrEnd"
	Parse_ExpectingFlagValue        ParseState = "Parse_ExpectingFlagValue"
)

type UnsetFlagNameSet added in v0.0.26

type UnsetFlagNameSet map[flag.Name]struct{}

func (UnsetFlagNameSet) Add added in v0.0.26

func (u UnsetFlagNameSet) Add(name flag.Name)

func (UnsetFlagNameSet) Contains added in v0.0.26

func (u UnsetFlagNameSet) Contains(name flag.Name) bool

func (UnsetFlagNameSet) Delete added in v0.0.26

func (u UnsetFlagNameSet) Delete(name flag.Name)

Directories

Path Synopsis
examples
package path provides a simple wrapper around a string path that can expand the users home directory, a common CLI need.
package path provides a simple wrapper around a string path that can expand the users home directory, a common CLI need.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL