warg

package module
v0.0.18 Latest Latest
Warning

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

Go to latest
Published: Apr 6, 2023 License: MIT Imports: 18 Imported by: 6

README

warg

Build hierarchical CLI applications with warg!

  • warg uses funcopt style declarative APIs to keep CLIs readable (nested commands can be indented!) and terse. warg does not require code generation.
  • warg is extremely interested in getting information into your app. Ensure a flag can be set from an environmental variable, configuration file, or default value by adding a single line to the flag declaration (configuration files also take some app-level config).
  • warg is customizable. Add new types of flag values, config file formats, or --help outputs using the public API.
  • Warg is easy to integrate into or remove from an existing codebase. This follows mostly from warg being terse and declarative. If you decide to remove warg, simply remove the app declaration and turn the passed flags into other types of function arguments for your command handlers. Done!

Examples

Full code for this example at ./examples/butler/main.go. Also see the godocs examples and other apps in the examples directory.

	app := warg.New(
		"butler",
		section.New(
			section.HelpShort("A virtual assistant"),
			section.Command(
				"present",
				"Formally present a guest (guests are never introduced, always presented).",
				present,
				command.Flag(
					"--name",
					"Guest to address.",
					scalar.String(),
					flag.Alias("-n"),
					flag.EnvVars("BUTLER_PRESENT_NAME", "USER"),
					flag.Required(),
				),
			),
		),
		warg.AddVersionCommand(""),
	)

Run Butler

Color can be toggled on/off/auto with the --color flag:

Sublime's custom image

The default help for a command dynamically includes each flag's current value and how it was was set (passed flag, config, envvar, app default).

Sublime's custom image

Of course, running it with the flag also works

Sublime's custom image

Apps Using Warg

  • fling - GNU Stow replacement to manage my dotfiles
  • grabbit - Grab images from Reddit
  • starghaze - Save GitHub Starred repos to GSheets, Zinc

Should You Use warg?

I'm using warg for my personal projects, but the API is not finalized and there are some known issues (see below). I will eventually improve warg, but I'm currently ( 2021-11-19 ) taking a break from developing on warg to develop some CLIs with warg.

Known Issues / Design Tradeoffs

  • lists containing aggregate values ( values in list objects from configs ) should be checked to have the same size and source but that must currently be done by the application ( see grabbit )
  • By design, warg does not support positional arguments. Instead, use required flags. See "Unsupported CLI Patterns" below.
  • By design, warg requires at least one subcommand. This makes adding additional subcommands easy. See "Unsupported CLI Patterns" below.

Alternatives

  • cobra is by far the most popular CLI framework for Go. It relies on codegen.
  • cli is also very popular.
  • I've used the now unmaintained kingpin fairly successfully.

Concepts

Sections, Commands, Flags, and Values

warg is designed to create hierarchical CLI applications similar to azure-cli (just to be clear, azure-cli is not built with warg, but it was my inspiration for warg). These apps use sections to group subcommands, and pass information via flags, not positional arguments. A few examples:

azure-cli
az keyvault certificate create --name <name> --vault-name <vault-name> --tag <key1=value1> --tag <key2=value2>

If we try to dissect the parts of this command, we see that it:

  • Starts with the app name (az).
  • Narrows down intent with a section (keyvault). Sections are usually nouns and function similarly to a directory hierarchy on a computer - used to group related sections and commands so they're easy to find and use together.
  • Narrows down intent further with another section (certificate).
  • Ends with a command (create). Commands are usually verbs and specify a single action to take within that section.
  • Passes information to the command with flags (--name, --vault-name).
  • Each flag is passed exactly one value (<name>, <vault-name>, and <key=value are placeholders for user-specified values). A flag can be typed multiple times to build aggregate data structures like slices or maps.

This structure is both readable and scalable. az makes hundreds of commands browsable with this strategy!

grabbit

grabbit is a much smaller app to download wallpapers from Reddit that IS built with warg. It still benefits from the sections/commands/flags structure. Let's organize some of grabbit's components into a tree diagram:

grabbit                   # app name
├── --color               # section flag
├── --config-path         # section flag
├── --help                # section flag
├── config                # section
│   └── edit              # command
│       └── --editor      # command flag
├── grab                  # command
│   └── --subreddit-name  # command flag
└── version               # command

Similar to az, grabbit organizes its capabilities with sections, commands and flags. Sections are used to group commands. Flags defined in a "parent" section are available to child commands. for example, the config edit command has access to the parent --config-path flag, as does the grab command.

Populating Values

Values can be populated from app defaults, and these can be overridden by environmental variables, and these can be overridden by values read from a config , and, finally, these can be overriden by values passed when invoking the app on the command line via CLI flags.

Specify type-independent options to populate values with FlagOpts and type-specific value options with options specific to each type of value (such as slices or scalars).

See an example.

Specially Handled Flags

--config

Because warg has the capability to read values from a config file into the app, and then override those values from other sources, a--config flag must be parsed before other flags. See the docs for API and an example.

--help and --color

As a special case, the--help/-h flag may be passed without a value. If so, the default help action will be taken. See OverrideHelpFlag and its example for how to change the default help action. By convention, the built-in help commands look for a flag called --color to control whether --help prints to the terminal in color. Use AddColorFlag to easily add this flag to apps and ConditionallyEnableColor to easily look for this flag in custom help.

Unsupported CLI Patterns

One of warg's tradeoffs is that it insists on only using sections, commands, flags, and values. This means it is not possible (by design) to build some styles of CLI apps. warg does not support positional arguments. Instead, use a required flag: git clone <url> would be git clone --url <url>.

All warg apps must have at least one nested command. 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.

Documentation

Overview

Declaratively create heirarchical command line apps.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func GoldenTest added in v0.0.18

func GoldenTest(t *testing.T, app App, args []string, lookup LookupFunc, updateGolden bool)

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. Pass updateGolden = true to update captured stdout and stderr files under the ./testdata dir (relative to test location).. From the CLI, call with WARG_TEST_UPDATE_GOLDEN=1 go test ./...

Types

type App

type App struct {

	// 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. Set to os.Stdout if not passed
	Stdout *os.File

	// 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. Set to os.Stdout if not passed
	Stderr *os.File
	// 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, rootSection section.SectionT, opts ...AppOpt) App

New builds a new App!

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() {
	app := warg.New(
		"newAppName",
		section.New(
			"work with a fictional blog platform",
			section.Command(
				"login",
				"Login to the platform",
				login,
			),
			section.Flag(
				"--timeout",
				"Optional timeout. Defaults to no timeout",
				scalar.Int(),
			),
			section.Flag(
				"--url",
				"URL of the blog",
				scalar.String(
					scalar.Default("https://www.myblog.com"),
				),
				flag.EnvVars("BLOG_URL"),
			),
			section.Section(
				"comments",
				"Deal with comments",
				section.Command(
					"list",
					"List all comments",
					// still prototyping how we want this
					// command to look,
					// so use a provided stub action
					command.DoNothing,
				),
			),
		),
	)

	// 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([]string{"blog.exe", "login"}, os.LookupEnv)
}
Output:

Logging into https://envvar.com

func (*App) MustRun

func (app *App) MustRun(osArgs []string, osLookupEnv LookupFunc)

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(osArgs []string, osLookupEnv LookupFunc) (*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/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",
		section.New(
			"demo flag overrides",
			section.Command(
				command.Name("show"),
				"Show final flag values",
				action,
				command.Flag(
					"--scalar-flag",
					"Demo scalar flag",
					scalar.String(
						scalar.Choices("a", "b"),
						scalar.Default("a"),
					),
					flag.ConfigPath("args.scalar-flag"),
					flag.Required(),
				),
				command.Flag(
					"--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[string]{
				scalar.Default("~/.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([]string{"calc", "-c", "testdata/ExampleFlagValueOptions/config.yaml", "show", "--scalar-flag", "b"}, os.LookupEnv)
}
Output:

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

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 AddColorFlag added in v0.0.16

func AddColorFlag() AppOpt

AddColorFlag adds a "--color" flag to the root section. By convention, this flag will be respected by the different help commands

func AddVersionCommand added in v0.0.16

func AddVersionCommand(version string) AppOpt

AddVersionCommand adds a "version" command to the root section that prints the version passed. Pass an empty string to use .Main.Version from `debug.ReadBuildInfo`, which returns "(devel)" when using `go run`

func ConfigFlag

func ConfigFlag(

	configFlagName flag.Name,

	scalarOpts []scalar.ScalarOpt[string],
	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/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",
		section.New(
			"do math",
			section.Command(
				command.Name("add"),
				"add integers",
				exampleConfigFlagTextAdd,
				command.Flag(
					flag.Name("--addend"),
					"Integer to add. Flag is repeatible",
					slice.Int(),
					flag.ConfigPath("add.addends"),
					flag.Required(),
				),
			),
		),
		warg.ConfigFlag(
			"--config",
			[]scalar.ScalarOpt[string]{
				scalar.Default("~/.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([]string{"calc", "-c", "testdata/ExampleConfigFlag/calc.yaml", "add"}, os.LookupEnv)
}
Output:

Sum: 6

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"
	"os"

	"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",
		section.New(
			"work with a fictional blog platform",
			section.Command(
				"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([]string{"blog.exe", "-h", "custom"}, os.LookupEnv)
}
Output:

Custom section help

func OverrideStderr added in v0.0.18

func OverrideStderr(f *os.File) AppOpt

func OverrideStdout added in v0.0.18

func OverrideStdout(f *os.File) AppOpt

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 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 ParseResult

type ParseResult struct {
	// Path to the command invoked. Does not include executable name (os.Args[0])
	Path []string // TODO: consider moving this inside the command.Context
	// Context holds the parsed information
	Context command.Context
	// Action holds the passed command's action to execute.
	Action command.Action
}

ParseResult holds the result of parsing the command line.

Jump to

Keyboard shortcuts

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