Files
summercms/docs/console/writing-commands.md
Jakub Zych 33460607db feat(summer): help, examples and hints for every tool command
- build runs go build under a spinner on a terminal and ends with
  'Built bin/<name> in <time>'; make:* print 'Created <path>' per file
- make:*, plugin:add, new, parity:*, docs:build, docs:sync and every
  delegate get long help and examples; delegates note that global options
  pass through to the application binary
- make:* failures (no summer.yaml, invalid plugin ID or name, existing
  artifact, unknown plugin) explain themselves and suggest a command
- docs:build and docs:sync return bonfire.Reported after the problem list
- TestCommandsDocumented covers the tool commands too
- docs: Upgrading to 0.4, delegates and global options, make: results and
  hints, the checklist for a finished command
2026-10-21 13:25:53 +02:00

15 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.

What a finished command has

The framework's own commands follow these rules, and so should a plugin's:

  • Description: one line in the imperative, sentence case, without a trailing period: "Publish a blog post by its slug".
  • Long help: what the command does, when to run it (by hand, from cron, from the scheduler), what each flag changes, and its exit codes.
  • Two or three examples: the common call first, then the flags people reach for.
  • A result line: a one-shot command ends with a line that says what it did, such as Published hello-world or Imported 120 posts (3 skipped), and returns an error when it failed, so the exit code is 1.
  • Data on stdout: what a script reads (a token, a key, --json output) goes through Println or Printf, which -q does not hide; information and success lines go through Info and Success, which it does.
  • Flags with a meaning people know: --json for output scripts read, --pretend or --dry-run for a run that changes nothing, --force to skip a confirmation. A destructive step asks through bonfire.ConfirmProceed, which never asks off an interactive terminal; never give an existing flag a new meaning.
  • A named logger: log through diary.For("<plugin code>") from a package variable, not slog.Default, so log.components can raise or silence the plugin. See Logging.

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.

An application binary also explains the most common mistake: running it from the wrong directory. When a command fails, the working directory has no config/ directory and a parent directory holds summer.yaml, the block says "This command runs from the application root." and suggests the same command from there:

 ERROR  lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)

  This command runs from the application root.

  Try:
    cd ../..; bin/acme migrate

Only the message changes: the binary still reads its configuration from config/ in the working directory. summer is not affected, because its commands find the application root themselves.

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.