Files
summercms/docs/console/writing-commands.md
Jakub Zych 3f38862b22 feat(bonfire): SSU widgets on charm v2 behind the Output interface
- Spinner, gradient progress bar and the multi-row process view ported
  from SSU to bubbletea v2 (input off, no signal handler, command
  context); boxed tables through lipgloss/table
- Prompts use huh fields and a single-select port of SSU's selector with
  its confirmation screen on an interactive terminal; line prompts off a
  terminal and under --no-ansi are unchanged (piped passwords still work)
- -q hides decoration but keeps data and errors; -n returns defaults and
  Secret fails with a hint on a terminal stdin; --no-ansi and non-TTY
  runs print today's plain lines
- Helpers: GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed,
  Process, Current, Failed, PrintAbove, ItemProgress, ErrAborted
- diary's styled console lines print above a running live view
2026-10-21 12:30:53 +02:00

13 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. On a terminal they are SSU's live views on the charm stack: a spinner, a gradient bar and boxed tables;
  • prompts: bonfire.Output.Ask, bonfire.Output.Confirm, bonfire.Output.Choice and bonfire.Output.Secret. On an interactive terminal they are huh fields and an arrow-key selector; otherwise they read lines from stdin, so a password can be piped in, and return their defaults when input ends, so a command run from cron or a script never hangs.

How much of this appears depends on where the command runs and on the global options:

Run Animation Decoration Data Prompts
Terminal spinner, progress bar, process view colors, symbols, boxed tables printed huh fields and the selector
Not a terminal none: [...] message and a line per tenth of progress symbols, no colors printed, tables tab-separated read a line from stdin; Confirm returns its default
-q none info, success and warning lines hidden; errors kept printed defaults, as with -n
--no-ansi none symbols, no colors printed plain line prompts
-n none as on the terminal printed defaults; Secret reads piped stdin or fails with a hint

Print the data your command exists to produce (a token, a key, JSON) with bonfire.Output.Println or bonfire.Output.Printf, never as a status line: -q keeps that output and drops only the decoration around it. bonfire.IsQuiet, bonfire.VerbosityOf and bonfire.GlobalsOf tell a command which options it runs under.

Before a destructive step, ask with bonfire.ConfirmProceed. It asks only on an interactive terminal; off a terminal, with -n or -q, and when the scheduler calls the command, it returns true without asking, so deploys over SSH never wait for an answer.

For a loop over many items, bonfire.Process runs a function per item and shows SSU's process view on a terminal, one row per item with its state, or prints one ✓ item or ✗ item: error line per item elsewhere; it returns the failures joined. Inside bonfire.Output.Progress, bonfire.Current names the item being worked on and bonfire.Failed records a failed one. Log lines and output printed while a live view runs appear above the view (bonfire.PrintAbove).

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.