Files
summercms/docs/console/writing-commands.md
Jakub Zych fb32886775 feat(bonfire): Artisan help, examples, JSON listing and error hints
- help <command> and <command> --help print Description, Usage,
  Arguments, Options with defaults, Global options, Help and Examples
- Command.Examples and Command.Hidden (appended, additive); list --all
  shows hidden commands, list --format=json prints a bonfire.Listing
- CheckDocs reports commands lacking description, long help, examples or
  argument and option descriptions
- Error block with the ERROR badge, WithHint explanation and Try lines
  ({bin} replaced), cobra suggestions as Try, -v error chain, messages
  scrubbed by sunscreen; Reported errors print nothing and exit 1
2026-10-21 12:21:24 +02:00

11 KiB

title, description, section, order
title description section order
Writing commands Add console commands to a plugin with bonfire.Command values, arguments, flags, styled output and prompts, and run commands in-process. console 40

Writing commands

A plugin adds console commands to the application binary the way a WinterCMS plugin calls registerConsoleCommand. In SummerCMS a command is a plain bonfire.Command value, and the plugin returns its commands from pact.HasCommands. summer make:command acme.blog Publish generates a starting point in console/publish.go.

Defining a command

A bonfire.Command has a name, a description, its positional arguments and flags, and a run function:

  • The name is in namespace:verb form, such as blog:publish. Plugin commands must use this form; only a few framework commands have bare names.
  • Each bonfire.Arg is a positional argument with a name, a description and a Required marker. The usage line shows required arguments as <name> and optional ones as [name].
  • Each bonfire.Flag is a string flag. Set bonfire.Flag.Bare for a switch such as --dry-run that stores true when given alone, and bonfire.Flag.Repeatable for a flag that can be given several times.
  • bonfire.Command.Run receives the context, a bonfire.Input and a bonfire.Output.
  • bonfire.Command.Long is optional extended help, shown under "Help:" by help <command> and <command> --help. bonfire.Command.Examples and bonfire.Command.Hidden are described below. Set bonfire.Command.Passthrough for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including --help, -h and --, reaches bonfire.Input.Args unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked.

Read arguments with bonfire.Input.Argument, scalar and bare flags with bonfire.Input.Flag, and repeatable flags with bonfire.Input.Flags, which returns the values in the order given:

publish := bonfire.Command{
	Name:        "blog:publish",
	Description: "Publish a post",
	Args:        []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
	Flags: []bonfire.Flag{
		{Name: "dry-run", Description: "Report without writing", Bare: true},
		{Name: "tag", Description: "Tag to add (repeatable)", Repeatable: true},
	},
	Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
		slug, _ := in.Argument("slug")
		dryRun, _ := in.Flag("dry-run")
		out.Printf("publish %s, tags %v, dry run %s\n", slug, in.Flags("tag"), dryRun)
		return nil
	},
}

// The generated main publishes a catalog of every command on the app.
catalog := bonfire.NewCatalog([]bonfire.Command{publish})
args := []string{"hello-world", "--tag", "news", "--tag", "go", "--dry-run"}
if err := catalog.Call(context.Background(), "blog:publish", args, os.Stdout); err != nil {
	fmt.Println(err)
}
// Output: publish hello-world, tags [news go], dry run true

Registering commands

Return the commands from the plugin's Commands method, which implements pact.HasCommands. The generated main appends every plugin's commands after the framework's runtime commands. Command names must be unique: when two commands share a name, or a command is named list or help, bonfire.NewRoot fails with bonfire.ErrDuplicateCommand naming the command, and the binary does not start. Keep your commands in your plugin's own namespace, such as blog:. The global options are reserved as well: a flag named verbose, quiet, ansi, no-ansi, no-interaction, env or help, or with the shorthand v, q, n or h, makes bonfire.NewRoot fail with bonfire.ErrReservedFlag.

A scaffolded plugin's Commands method returns the generated list of everything in console/, so commands created with summer make:command are registered without editing plugin.go.

Output

bonfire.Output is the console your command writes to. Besides bonfire.Output.Printf and bonfire.Output.Println, it provides:

  • status lines: bonfire.Output.Info, bonfire.Output.Success, bonfire.Output.Warning and bonfire.Output.Error (the last one writes to the error stream);
  • widgets: bonfire.Output.Table, bonfire.Output.Spinner around a function and bonfire.Output.Progress for a progress bar, which fall back to plain lines when the output is not a terminal;
  • prompts: bonfire.Output.Ask, bonfire.Output.Confirm, bonfire.Output.Choice and bonfire.Output.Secret. Prompts return their defaults when input ends, so a command run from cron or a script never hangs.

Return an error from Run to fail the command. The binary prints it as an error block on stderr and exits with status 1; see Errors and hints.

Examples and hidden commands

bonfire.Command.Examples lists usage examples. Each bonfire.Example holds the command line without the binary and a short description; help prints the description as a # comment and the line after bin/<name> (or summer for the tool's own commands), so one example serves both. bonfire.Command.Hidden keeps a command that still runs out of the list; list --all and list --format=json show it:

export := bonfire.Command{
	Name:        "blog:export",
	Description: "Export published posts as JSON",
	Long:        "Writes every published post, newest first, to a JSON file.",
	Flags: []bonfire.Flag{
		{Name: "path", Description: "Output file", Default: "posts.json"},
	},
	Examples: []bonfire.Example{
		{Command: "blog:export --path=backup.json", Description: "Export to another file"},
	},
	Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
		return nil
	},
}
// Hidden commands run, but list shows them only with --all.
cleanup := bonfire.Command{
	Name:        "blog:cleanup",
	Description: "Remove orphaned uploads",
	Hidden:      true,
}

root, err := bonfire.NewRootIO("acme", []bonfire.Command{export, cleanup}, strings.NewReader(""), os.Stdout, os.Stdout)
if err != nil {
	fmt.Println(err)
	return
}
bonfire.Execute(context.Background(), root, []string{"help", "blog:export"})
// Output:
// Description:
//   Export published posts as JSON
//
// Usage:
//   blog:export [options]
//
// Options:
//       --path=PATH  Output file [default: "posts.json"]
//
// Global options:
//   -h, --help            Display help for the given command
//   -q, --quiet           Do not output any message
//   -v|vv|vvv             Increase verbosity
//       --ansi|no-ansi    Force (or disable) ANSI output
//   -n, --no-interaction  Do not ask any interactive question
//       --env             The environment the command runs under
//
// Help:
//   Writes every published post, newest first, to a JSON file.
//
// Examples:
//   # Export to another file
//   bin/acme blog:export --path=backup.json

list --format=json prints every command with its arguments, options (with defaults) and examples as a bonfire.Listing, for documentation checks, scripts and agents. bonfire.Describe builds the same listing from a command slice.

Errors and hints

When Run returns an error, the binary prints a block on stderr: the ERROR badge and the message, then "Run with -v for details.". With -v the block lists the wrapped errors under "Caused by:" instead. Messages pass through sunscreen.Scrub, so a token inside an error text is not printed.

Wrap an error with bonfire.WithHint to explain it and to say what to run next. {bin} in the Try text becomes bin/<name> or summer, so the hint names the right program:

publish := bonfire.Command{
	Name:        "blog:publish",
	Description: "Publish a draft post",
	Args:        []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
	Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
		slug, _ := in.Argument("slug")
		return bonfire.WithHint(fmt.Errorf("blog: no draft named %q", slug),
			"Only drafts can be published; published posts keep their slug.",
			"{bin} blog:drafts")
	},
}

root, err := bonfire.NewRootIO("acme", []bonfire.Command{publish}, strings.NewReader(""), os.Stdout, os.Stdout)
if err != nil {
	fmt.Println(err)
	return
}
code := bonfire.Execute(context.Background(), root, []string{"blog:publish", "hello-world"})
fmt.Println("exit code:", code)
// Output:
// ERROR  blog: no draft named "hello-world"
//
//   Only drafts can be published; published posts keep their slug.
//
//   Try:
//     bin/acme blog:drafts
//
//   Run with -v for details.
//
// exit code: 1

A command that has already printed its problems, such as a checker that lists every finding, returns bonfire.Reported(err): the binary exits 1 and prints nothing more. A mistake in how the command was called, such as an unknown flag or a missing argument, exits 2 and the block suggests help <command>; wrap your own usage checks with bonfire.Usage to get the same exit code.

Documentation coverage

bonfire.CheckDocs reports each command that lacks a description, long help, an example, or a description of one of its arguments or options. It only reports; the framework's own test fails on any problem, and a plugin repository can call it from a test and log the problems:

commands := []bonfire.Command{
	{
		Name:        "blog:publish",
		Description: "Publish a draft post",
		Long:        "Publishes the draft with the given slug and notifies subscribers.",
		Args:        []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
		Examples:    []bonfire.Example{{Command: "blog:publish hello-world"}},
	},
	{
		Name:        "blog:reindex",
		Description: "Rebuild the search index",
		Flags:       []bonfire.Flag{{Name: "batch", Default: "500"}},
	},
}
for _, problem := range bonfire.CheckDocs(commands) {
	fmt.Println(problem)
}
// Output: blog:reindex: missing long help, examples, option --batch description

Calling commands in-process

bonfire.Call runs one command of a slice by name with its arguments and writes the output to any writer, like Artisan::call in Laravel. Tests use it to exercise a command without building a binary:

commands := []bonfire.Command{{
	Name:        "acme:greet",
	Description: "Greet someone by name",
	Args:        []bonfire.Arg{{Name: "name", Required: true}},
	Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
		name, _ := in.Argument("name")
		out.Printf("Hello, %s\n", name)
		return nil
	},
}}

if err := bonfire.Call(context.Background(), commands, "acme:greet", []string{"blog"}, os.Stdout); err != nil {
	fmt.Println(err)
}
// Output: Hello, blog

The generated main also publishes the application's complete command list as a bonfire.Catalog on the container. Code outside the command line, such as the scheduler, looks it up and calls commands through bonfire.Catalog.Call, and checks for one with bonfire.Catalog.Has.

Running your command

After summer build, your command is part of the binary:

./bin/hello greeter:hello

That command comes from the greeter plugin of examples/hello, whose Commands method returns one bonfire.Command.