goclikit

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Sep 2, 2026 License: MIT Imports: 10 Imported by: 0

README

goclikit

Go Reference CI Go Report Card

What a cobra CLI's main calls instead of rootCmd.Execute().

func main() {
    root := newRootCommand()
    root.AddCommand(goclikit.UpdateCommand(cfg))

    if err := goclikit.Execute(context.Background(), root, autoCfg); err != nil {
        if !errors.Is(err, goclikit.ErrReported) {
            fmt.Fprintln(os.Stderr, "error:", err)
        }
        if errors.Is(err, goclikit.ErrUsage) {
            os.Exit(2)
        }
        os.Exit(1)
    }
}

Install

go get github.com/datapointchris/goclikit

What it answers

A bare cobra tree leaves four things to each program. Every program that answers them separately answers them differently.

Exit codes

Cobra returns a mistyped command line and a command that ran and failed as the same kind of error, so both exit 1. A script cannot tell "you typed it wrong, retry with different arguments" from "it ran and failed".

Execute classifies the first as ErrUsage, and the caller selects exit 2 — the shell convention, and what Python's argparse does.

Cobra cannot classify everything. An argument-count failure and a custom Args validator come back indistinguishable from a RunE failure without matching on message text, which a caller must never have to do. Mark those at the source:

Args: func(cmd *cobra.Command, args []string) error {
    if len(args) > 1 {
        return goclikit.UsageError(fmt.Errorf("unknown command %q", args[0]))
    }
    return nil
},
The alternatives, and the next command

Cobra names the token it rejected and stops. The flag set it had just consulted goes unnamed, and the --help pointer is printed only where the resolved command has not silenced its own error output — a field set for unrelated reasons, which is why two CLIs sharing one bootstrap answer the same mistake differently.

$ tool search --ownd
error: unknown flag: --ownd

Did you mean this?
  --owned

Run 'tool search --help' for usage.

Suggestions come from cobra's own rule for commands: within SuggestionsMinimumDistance edits of a real flag, or a prefix of one, and off entirely under DisableSuggestions. One rule then answers a mistyped flag and a mistyped command on the same line. Near matches rather than the whole flag set, so a wide flag surface does not answer one typo with a wall.

The prose is a suffix on the error, so errors.Is and errors.As still reach whatever cobra, pflag or a caller's own FlagErrorFunc produced.

Recovery from a missing resource

An error is read by someone who is stuck right now, so it owes what failed, what was expected, and the one command that changes the situation. A not-found usually gives only the first.

A tool small enough to list its corpus should list it — naming a list command is a worse error than printing the valid values. This is for the tools that cannot: a store with more ids than fit on a screen, where the answer is the command that searches it.

Annotate the resource commands, and tell Execute what a not-found looks like:

items := goclikit.WithRecoveryHints(newItemsCommand(),
    "Search items by title: tool items search <query>",
    "Completed items are hidden: tool items list --status all",
)

goclikit.Execute(ctx, root, autoCfg, goclikit.WithNotFound(notFound))
func notFound(err error) (subject string, ok bool) {
    var apiErr *api.APIError
    if !errors.As(err, &apiErr) || !apiErr.NotFound() {
        return "", false
    }
    return apiErr.Message, true
}
$ tool items show 999999
error: item 999999 not found
  Search items by title: tool items search <query>
  Completed items are hidden: tool items list --status all

The classifier is the only part a tool has to supply, because it is the only part cobra cannot know. A tool backed by a local store branches on its own sentinel instead of a status code; the mechanism is the same.

Hints are read from the nearest annotated ancestor, and do not accumulate up the tree. A nested resource is not its parent, so adding the outer group's hints would send someone after the wrong noun.

The update check

UpdateCommand returns the update subcommand, and Execute races the version check alongside whatever else was typed — the check starts before the command and the notice prints after it, so a fast command pays nothing and the line is not buried in the output. This is gh's shape, and it is why there is no blocking mode.

The check never fires for update, version, completion, help, or any line carrying --help. Cobra's shell-completion callback runs on every TAB press, so a check there would add latency to something that must feel instant.

The machinery underneath is goselfupdate, which is where the release fetch, the checksum verification, the atomic binary replacement and the interval gate live.

One import, one line

Nothing else in a consuming CLI imports this package. That is the constraint every feature here is designed against.

A tool that wants to answer a mistake its own way deletes two lines from main, and everything it wrote itself still compiles. A feature that reached out to call sites — a wrapper each command had to remember, a helper spread across twenty files — would trade that away.

WithRecoveryHints is therefore a convenience and never a requirement. The contract is the annotation, and its key is exported, so a CLI can write it directly and keep this package out of its command files:

cmd.Annotations = map[string]string{
    goclikit.RecoveryHintsAnnotation: strings.Join(hints, "\n"),
}

Design decisions

The not-found is classified by the consumer, not detected here. Cobra cannot tell a missing resource from a timeout, and the subject is the tool's too — only the server knows which of the two ids in tool items untag 8 4 was absent, so a subject composed here from the arguments would name the wrong number half the time.

A not-found carrying no subject is left alone. A proxy error page or an auth redirect decodes to a status and nothing else, and the resource was never reached. Rewriting that into a resource claim would name a thing the tool does not have, and a wrong base URL and a missing id have different remedies.

The hint attaches once, to the command Execute already resolved. Cobra has been asked which command the line names before anything runs, so wrapping every RunE in the tree would be redoing work already done. It also reaches a not-found raised before RunE — in a PersistentPreRunE resolving a flag — which a RunE wrapper cannot see.

A usage error is never converted to a not-found. A line cobra rejected never reached a resource. Exit code 2 survives a consumer's classifier saying otherwise.

Execute takes options rather than more parameters. A CLI wanting none of this calls it with three arguments, and a feature added later does not reach the call sites that never asked for it.

Options are composed with what the caller already set, never replacing it. A consumer's own FlagErrorFunc still runs, and its error type still survives errors.As.

License

MIT

Documentation

Overview

Package goclikit is what a cobra CLI's main calls instead of cobra.Command.Execute.

A bare cobra tree leaves four things to each program, and every program that answers them separately answers them differently. This package answers them once:

  • Exit codes. Cobra returns a mistyped command line and a command that ran and failed as the same kind of error, so both exit 1. Execute classifies the first as ErrUsage and the caller selects 2.
  • Alternatives. Cobra names the token it rejected and stops, without the flags it had just consulted. Execute appends the near matches.
  • The next command. Cobra prints "Run '... --help' for usage" only where the resolved command has not silenced its own error output, which is a field set for unrelated reasons. Execute puts it in the error, so every tool prints it exactly once.
  • Recovery from a missing resource. A tool that cannot list its corpus can still name the command that searches it. [WithNotFoundHints] records those commands and Execute attaches them, with the tool supplying a NotFoundFunc to say what a not-found looks like.

The update command is the fifth thing, and it is the reason github.com/datapointchris/goselfupdate is a dependency rather than a sibling: UpdateCommand returns the subcommand and Execute races the version check alongside whatever else was typed.

One import, one line

func main() {
	root := newRootCommand()
	root.AddCommand(goclikit.UpdateCommand(cfg))

	if err := goclikit.Execute(context.Background(), root, autoCfg); err != nil {
		if !errors.Is(err, goclikit.ErrReported) {
			fmt.Fprintln(os.Stderr, "error:", err)
		}
		if errors.Is(err, goclikit.ErrUsage) {
			os.Exit(2)
		}
		os.Exit(1)
	}
}

Nothing else in a consuming CLI imports this package. That is deliberate and it is the constraint every feature here is designed against: a tool that wants to answer a mistake its own way deletes two lines, and everything it wrote itself still compiles. A feature reaching out to call sites — an annotation helper spread across twenty files, a wrapper each command has to remember — would trade that away, so [WithNotFoundHints] is offered for convenience and never required. A CLI may write the annotation itself.

Index

Constants

View Source
const RecoveryHintsAnnotation = "goclikit.recovery-hints"

RecoveryHintsAnnotation is the cobra.Command annotation carrying the commands a not-found under it should name, one per line.

Exported so a CLI can write the annotation itself and keep this package out of its command files. WithRecoveryHints is the same thing with the join done for you.

Variables

View Source
var ErrReported = errors.New("already reported")

ErrReported wraps every error UpdateCommand's command returns, marking it as already written to stderr. A program whose main prints the error returned by Execute should skip anything matching this, otherwise the failure is reported twice:

if err := root.Execute(); err != nil {
	if !errors.Is(err, goclikit.ErrReported) {
		fmt.Fprintln(os.Stderr, "error:", err)
	}
	os.Exit(1)
}

Cobra's own error printing is already suppressed on the command.

View Source
var ErrUsage = errors.New("usage error")

ErrUsage marks a failure caused by how the command was typed rather than by the command running and failing: an unknown or malformed flag, or an unknown subcommand. Select exit code 2 for it, as the shell convention and Python's argparse do, so a caller can tell "you typed it wrong" from "it ran and failed" -- only the former is worth retrying with different arguments.

Cobra reports both as ordinary errors, which is what flattens every failure to exit 1 without this.

Argument-count and custom cobra.Command.Args validation failures are not classified: cobra returns them as plain errors indistinguishable from a RunE failure without matching on message text, which callers must never have to do.

Functions

func Execute

func Execute(ctx context.Context, root *cobra.Command, config autoupdate.Config, options ...Option) error

Execute runs root with an update check racing alongside it.

The check starts before the command and the notice prints after it, so a fast command pays nothing and the line is not buried in the command's output. This is gh's shape, and it is the reason there is no blocking mode.

A command line cobra rejects comes back classified as ErrUsage and carrying the near-matching flags and the command that lists the valid ones, which cobra reports neither of.

func main() {
	if err := goclikit.Execute(context.Background(), rootCmd, autoConfig()); err != nil {
		if !errors.Is(err, goclikit.ErrReported) {
			fmt.Fprintln(os.Stderr, err)
		}
		if errors.Is(err, goclikit.ErrUsage) {
			os.Exit(2)
		}
		os.Exit(1)
	}
}

A not-found comes back carrying the commands that find a real id, for a caller that supplied WithNotFound and annotated its resource commands with WithRecoveryHints. Without the option nothing about an error changes.

Deliberately not a PersistentPreRun: cobra runs only the *closest* PersistentPreRunE in the ancestry, so a hook here would work for a root that has none and silently do nothing for one that does.

func UpdateCommand

func UpdateCommand(cfg goselfupdate.Config, options ...Options) *cobra.Command

New returns an update command for cfg.

func UsageError

func UsageError(err error) error

UsageError marks err as ErrUsage so Execute's caller selects exit code 2 for it, leaving the message alone.

For the mistakes this package cannot detect on its own: an argument-count or custom cobra.Command.Args failure, which cobra returns indistinguishably from a RunE failure, and a required-flag or mutually-exclusive-flag rule a command validates itself.

Args: func(cmd *cobra.Command, args []string) error {
	if len(args) > 1 {
		return goclikit.UsageError(fmt.Errorf("unknown command %q", args[0]))
	}
	return nil
},

Without this a consumer has to declare its own marker type to say the same thing, which is how four CLIs here ended up with four copies of it.

func WithRecoveryHints

func WithRecoveryHints(cmd *cobra.Command, hints ...string) *cobra.Command

WithRecoveryHints records on cmd the commands a not-found under it should name, and returns cmd so it can be attached inline in an AddCommand list.

Each hint is a sentence, a colon, and the command to run.

Cobra does not inherit annotations and the lookup takes the nearest ancestor carrying them, so a subcommand acting on a second kind of id names every way in: a verb taking both an item and one of its tasks lists both.

Types

type NotFoundFunc

type NotFoundFunc func(err error) (subject string, ok bool)

NotFoundFunc reports whether err is this tool's not-found, and the line naming what was missing.

Two answers from one call because they come from the same place. Cobra cannot tell a missing resource from a timeout, and the subject is the tool's too: only the server knows which of the two ids in `tool items untag 8 4` was the one that was absent, so a subject composed here from the arguments would name the wrong number half the time.

Returning an empty subject is the same as returning false. A tool whose not-found sometimes carries no detail — a proxy error page, an auth redirect decoding to a status and nothing else — reports it that way, and the error is left alone rather than rewritten into a resource claim nothing established.

type Option

type Option func(*settings)

Option adjusts what Execute does beyond running the command tree.

Variadic rather than fields on a struct parameter, so a CLI wanting none of it calls Execute with three arguments and a feature added here never reaches the ten call sites that did not ask for it.

func WithNotFound

func WithNotFound(classify NotFoundFunc) Option

WithNotFound tells Execute how to recognize this tool's not-found, which is the one thing it cannot work out for itself.

A tool backed by an HTTP API branches on its status code; one backed by a local store branches on its own sentinel:

goclikit.WithNotFound(func(err error) (string, bool) {
	var apiErr *api.APIError
	if !errors.As(err, &apiErr) || !apiErr.NotFound() {
		return "", false
	}
	return apiErr.Message, true
})

Without it a not-found is an ordinary error and the recovery annotations are never read.

type Options

type Options struct {
	// Use overrides the command name. Defaults to "update".
	//
	// There is deliberately no alias knob. `update` is the fleet's one
	// self-update verb, and an alias is what let `upgrade` coexist with it
	// across every CLI without anyone choosing it.
	Use string

	// Changelog prints the commits between the two versions after a
	// successful update. Enabled by default; it costs one extra request and is
	// skipped silently when the source cannot produce one.
	SkipChangelog bool
}

Options adjusts the generated command.

Jump to

Keyboard shortcuts

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