Help us shape the future of Go! Take the 2026 Go Developer Survey

clix

package module
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 17 Imported by: 6

README

Clix

CLI convenience module for Frostyard tools.

Wraps charm.land/fang/v2 and spf13/cobra to provide standardized version strings, common flags, JSON output helpers, and a reporter factory. Consuming CLIs only need to define their own commands.

Install

go get github.com/frostyard/clix

Usage

package main

import (
	"fmt"
	"os"

	"github.com/frostyard/clix"
	"github.com/spf13/cobra"
)

// Set via ldflags at build time.
var (
	version = "dev"
	commit  = "none"
	date    = "unknown"
	builtBy = "local"
)

func main() {
	app := clix.App{
		Version: version,
		Commit:  commit,
		Date:    date,
		BuiltBy: builtBy,
	}

	rootCmd := &cobra.Command{
		Use:   "mytool",
		Short: "An example CLI built with clix",
		RunE: func(cmd *cobra.Command, args []string) error {
			// Use the reporter for progress output
			r := clix.NewReporter()
			r.Message("doing work...")

			result := map[string]string{"status": "ok"}

			// OutputJSON writes to stdout when --json is set. An error means
			// the JSON could not be encoded (a complete fallback envelope may
			// have been written) or a complete document did not reach stdout;
			// either way, report it rather than falling through to text.
			written, err := clix.OutputJSON(result)
			if err != nil {
				return err
			}
			if written {
				return nil
			}

			// Fall through to text output
			fmt.Println("done")
			return nil
		},
	}

	if err := app.Run(rootCmd); err != nil {
		os.Exit(1)
	}
}

When a command fails, fang renders the error to stderr and App.Run returns it, so the caller owns the process exit code — os.Exit(1) here is what makes mytool exit non-zero (call clix.OutputJSONError inside a command's RunE to emit the JSON error envelope under --json).

clix renders that error with its own fang error handler:

  • on a terminal, every line of a multi-line error (for example an errors.Join of two failures) keeps its own line, because the width style is applied to each line;
  • a first word that is a flag keeps its lower case: --clear cannot be combined with --other, not --Clear cannot be combined with --other;
  • the closing period is added only when the text does not already end in ., !, ? or :, so failed. is not printed as failed..;
  • a usage error keeps fang's Try --help for usage. hint.

Output that does not go to a terminal (a pipe, a journal, a redirected stderr) is the plain err.Error() and one newline: no ERROR header, no style, no closing period and no usage hint, which is the non-terminal path fang documents but cannot reach, because it wraps stderr in a writer that hides the file. This differs from clix v0.4.0 and main, which printed fang's styled block there, with only the ANSI sequences stripped.

Build with ldflags for version injection:

go build -ldflags "-X main.version=1.0.0 -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -I) -X main.builtBy=ci"

Flags

App.Run() automatically registers these persistent flags on the root command:

Flag Short Description
--json Output in JSON format
--verbose -v Verbose output
--dry-run -n Dry run mode (no actual changes)
--silent -s Suppress all progress output

Flag values are available as package-level variables: clix.JSONOutput, clix.Verbose, clix.DryRun, clix.Silent.

These four flags — --json, --verbose/-v, --dry-run/-n, and --silent/-s — are reserved by clix across the whole command tree: if your root command or any subcommand at any depth already defines one of those names or shorthands (persistent or local), App.Run() returns an error naming the command and the collision instead of panicking, before anything executes. (cobra merges each command's local flags with its ancestors' persistent flags at parse time, so a subcommand shorthand collision would otherwise panic only when that subcommand runs, and a name collision would silently shadow clix's flag.)

Calling App.Run() or App.RunContext() more than once against the same root command is safe: the flags clix registered on an earlier call are recognized and reused rather than re-registered, so a test or a REPL-style caller can execute the same root repeatedly without tripping its own reserved-flag collision check. A consumer-defined flag that collides with a reserved name or shorthand is still rejected exactly as on the first call. Each repeated invocation starts isolated: a reused flag is reset to its declared default and its Changed state cleared before that invocation's arguments are parsed, so a --json passed on one run cannot leak into a later run that omits it, and BindViper's command-line-over-viper precedence sees only the current invocation's explicit flags.

RunContext

App.Run(cmd) is a thin wrapper around App.RunContext(context.Background(), cmd). Call RunContext directly when you want to bound execution with a deadline/timeout or let a supervising process (a test, a parent command, an orchestrator) cancel the run instead of relying solely on the SIGINT/SIGTERM handling Run installs:

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := app.RunContext(ctx, rootCmd); err != nil {
	os.Exit(1)
}

RunContext performs the same nil-command check, defaulting, and flag registration as Run, then runs fang.Execute with the supplied ctx; a command's RunE observes cancellation via cmd.Context().Err().

Reporter

clix.NewReporter() returns a reporter based on the active flags:

Priority Condition Reporter Output
1 (highest) --silent NoopReporter none
2 --json JSONReporter stdout
3 (default) neither TextReporter stderr

Silent always takes priority over JSON. Text output goes to stderr to keep stdout clean for data.

Testing output

Every clix output path — OutputJSON, OutputJSONError, and the reporters from NewReporter — writes through two package-level writer seams, clix.Stdout and clix.Stderr. Both default to nil, meaning "the current os.Stdout / os.Stderr at call time", so production behavior is unchanged and consumers that already swap os.Stdout in tests keep working. Stdout / Stderr are shared package-level variables, so tests that set them should not run in parallel unless they coordinate access. In tests, assign a bytes.Buffer instead of touching the process globals:

func TestStatusJSON(t *testing.T) {
	var out bytes.Buffer
	clix.Stdout = &out
	defer func() { clix.Stdout = nil }()
	clix.JSONOutput = true
	defer func() { clix.JSONOutput = false }()

	// ... run the command ...

	var got map[string]any
	if err := json.Unmarshal(out.Bytes(), &got); err != nil {
		t.Fatal(err)
	}
}

Viper Integration

If your CLI uses spf13/viper for config, bind the common flags in PersistentPreRunE:

rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
	return clix.BindViper(cmd)
}

This binds --json, --verbose, --dry-run, and --silent to viper keys so they can be set via config files or environment variables. cobra runs the root's PersistentPreRunE with cmd set to the command actually executing, and BindViper resolves the flags through that command's local, persistent, and inherited sets, so this one hook works for the root and every subcommand. Calling it before App.Run has registered the flags returns clix: BindViper: --json is not registered on "<cmd>"; call App.Run on the root command first. A nil cmd returns clix: BindViper: command is nil rather than panicking.

BindViper also writes a viper-sourced value back onto the flag, so a config file or environment variable actually changes clix.JSONOutput, clix.Verbose, clix.DryRun, and clix.Silent — binding alone would leave the value inside viper, where nothing clix reads would see it.

Precedence is command line > viper > default. A flag the user passed is never overwritten, so an explicit --json=false wins over json: true in a config file. Call BindViper from PersistentPreRunE (not RunE) so the write-back happens before your RunE reads those variables.

Development

make test            # run all tests
make lint            # run golangci-lint
make check           # format + lint + test + coverage-checker self-test + 95% floor
                     # + zero-covered-function guard (pre-commit gate)
make bump            # tag next semver with svu and push; the tag push publishes the GitHub release

The e2e suite in tests/e2e/ builds a consumer program wired like the example above and runs it as a subprocess. Contributing guide: AGENTS.md (CONTRIBUTING.md resolves to it).

Documentation

Overview

Package clix provides CLI convenience functions for Frostyard tools, wrapping charm.land/fang/v2 and spf13/cobra with standardized version injection, common flags, JSON output helpers, and reporter factory.

Index

Constants

This section is empty.

Variables

View Source
var (
	JSONOutput bool // --json flag value
	Verbose    bool // --verbose / -v flag value
	DryRun     bool // --dry-run / -n flag value
	Silent     bool // --silent / -s flag value
)

Common flag values, populated when Run() registers persistent flags.

View Source
var Stderr io.Writer

Stderr, when non-nil, replaces the process's standard error for the text reporter returned by NewReporter. Same contract as Stdout: nil means "use the current os.Stderr at call time".

View Source
var Stdout io.Writer

Stdout, when non-nil, replaces the process's standard output for every clix output path (OutputJSON, OutputJSONError, and the JSON reporter from NewReporter). It exists so tests — in clix and in consuming CLIs — can capture output with a bytes.Buffer instead of swapping os.Stdout. It defaults to nil, which means "use the current os.Stdout at call time", so consumers that already swap os.Stdout keep working unchanged.

Functions

func BindViper

func BindViper(cmd *cobra.Command) error

BindViper binds the common flags (--json, --verbose, --dry-run, --silent) to viper keys of the same names. Call it from a PersistentPreRunE if your app uses viper for config management. cobra runs the root's PersistentPreRunE with cmd set to the command actually executing, so BindViper resolves each flag through that command's local, persistent, and inherited flag sets — it is safe to call from any command in the tree, root or subcommand. It returns a clix-namespaced error when a flag is not registered on cmd, which means App.Run has not registered the flags on the root command yet. A nil cmd returns `clix: BindViper: command is nil` rather than panicking, so a mis-wired PersistentPreRunE surfaces as an ordinary error.

func NewReporter

func NewReporter() reporter.Reporter

NewReporter returns the appropriate reporter based on the Silent and JSONOutput flags. Silent mode: NoopReporter (suppresses all output). Silent takes priority over JSON. JSON mode: JSONReporter writing to stdout (for piping/parsing). Text mode: TextReporter writing to stderr (keeps stdout clean for data). The writers are resolved through Stdout / Stderr when set, otherwise the current os.Stdout / os.Stderr.

func OutputJSON

func OutputJSON(data any) (bool, error)

OutputJSON writes data as indented JSON to stdout if JSONOutput is true. Returns (false, nil) when JSON mode is not active. The boolean means "a document reached the writer": data is encoded before the writer is touched, and

  • if encoding fails, a fallback error envelope {"error": true, "message": "failed to encode JSON: …"} is written instead and the encoding error is returned alongside true; if that fallback write fails or is short, false is returned with both errors;
  • if the write itself fails or reports a short write (closed pipe, full disk, a failing Stdout seam), the complete document did not reach the writer: no fallback is attempted and (false, "write JSON output: …") is returned.

Output goes to Stdout when set, otherwise to os.Stdout.

func OutputJSONError

func OutputJSONError(message string, err error) error

OutputJSONError writes a structured error object as JSON to stdout and returns a wrapped error for the caller to propagate. If err is nil, the message alone is used as the error text. If writing the JSON envelope fails, the returned error preserves both the caller's error and the output failure.

Types

type App

type App struct {
	Version string
	Commit  string
	Date    string
	BuiltBy string
}

App holds build-time metadata for a CLI application. Create one in main() and call Run() to execute the root command.

func (*App) Run

func (a *App) Run(cmd *cobra.Command) error

Run registers common persistent flags on cmd, then executes the command via fang.Execute with the formatted version string and signal handling.

The flags --json, --verbose/-v, --dry-run/-n, and --silent/-s are reserved by clix across the entire command tree: the check covers the root command and every descendant at any depth, on both persistent and local flag sets. If cmd or any subcommand already defines one of those names or shorthands, Run returns an error naming the collision before executing anything, rather than letting pflag panic.

Run is a thin wrapper around RunContext using context.Background(); use RunContext directly when the caller needs to bound execution with a deadline or let a supervising process cancel the run.

func (*App) RunContext added in v0.5.0

func (a *App) RunContext(ctx context.Context, cmd *cobra.Command) error

RunContext is Run with a caller-supplied context: it registers common persistent flags on cmd, then executes the command via fang.Execute with the formatted version string and signal handling, running under ctx.

Prefer RunContext over Run when the caller wants to bound execution with a deadline or timeout, or let a supervising process (a test, a parent command, an orchestrator) cancel the run instead of relying solely on the OS signal handling fang.Execute installs for SIGINT/SIGTERM.

RunContext renders a command error with clix's own fang error handler (errors.go): on a terminal each line of a multi-line error, such as an errors.Join error, keeps its own line and a leading flag keeps its lower case; a writer that is not a terminal (a pipe, a journal, a redirected stderr) gets the plain err.Error() and one newline, with no header, style, closing period or usage hint.

The flags --json, --verbose/-v, --dry-run/-n, and --silent/-s are reserved by clix across the entire command tree: the check covers the root command and every descendant at any depth, on both persistent and local flag sets. If cmd or any subcommand already defines one of those names or shorthands, RunContext returns an error naming the collision before executing anything, rather than letting pflag panic.

func (*App) VersionString

func (a *App) VersionString() string

VersionString returns a formatted version string including commit, date, and builder info. Example: "1.2.3 (Commit: abc) (Date: 2026-01-01) (Built by: ci)"

Directories

Path Synopsis
tests
e2e/exampletool command
Command exampletool is the consumer program clix's end-to-end suite builds and runs as a subprocess.
Command exampletool is the consumer program clix's end-to-end suite builds and runs as a subprocess.

Jump to

Keyboard shortcuts

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