From 5343bdc792fa0e160fd80c94bc787f3e3097930e Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 02:42:16 +0100 Subject: [PATCH 01/15] cli-plugins/manager: Plugin.RunHook: improve error message Currently, the error was a plain "exit status 1"; make the error message more informative if we need it :) Signed-off-by: Sebastiaan van Stijn --- cli-plugins/manager/plugin.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go index 824d82d12f1f..f5ec6a348f5a 100644 --- a/cli-plugins/manager/plugin.go +++ b/cli-plugins/manager/plugin.go @@ -163,12 +163,16 @@ func (p *Plugin) RunHook(ctx context.Context, hookData HookPluginData) ([]byte, pCmd := exec.CommandContext(ctx, p.Path, p.Name, metadata.HookSubcommandName, string(hDataBytes)) // #nosec G204 -- ignore "Subprocess launched with a potential tainted input or cmd arguments" pCmd.Env = os.Environ() pCmd.Env = append(pCmd.Env, metadata.ReexecEnvvar+"="+os.Args[0]) - hookCmdOutput, err := pCmd.Output() + + out, err := pCmd.Output() if err != nil { - return nil, wrapAsPluginError(err, "failed to execute plugin hook subcommand") + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil, wrapAsPluginError(err, "plugin hook subcommand exited unsuccessfully") + } + return nil, wrapAsPluginError(err, "failed to execute plugin hook subcommand: "+pCmd.String()) } - - return hookCmdOutput, nil + return out, nil } // pluginNameFormat is used as part of errors for invalid plugin-names. From 0501cf829347c875aef9218c92129b59da7c2385 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 11:47:21 +0100 Subject: [PATCH 02/15] cli-plugins/manager: simplify ctx-cancel check Signed-off-by: Sebastiaan van Stijn --- cli-plugins/manager/hooks.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cli-plugins/manager/hooks.go b/cli-plugins/manager/hooks.go index 6a3212315f9a..b01f63b628ed 100644 --- a/cli-plugins/manager/hooks.go +++ b/cli-plugins/manager/hooks.go @@ -55,11 +55,8 @@ func runHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subComma } func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []string { - // check if the context was cancelled before invoking hooks - select { - case <-ctx.Done(): + if ctx.Err() != nil { return nil - default: } pluginsCfg := cfg.Plugins From dd91ed3f2d082d652f06c082fa48ab98f38abaad Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 02:38:09 +0100 Subject: [PATCH 03/15] cli-plugins/manager: refactor for easier debugging Extract the code inside the loop to a closure, so that we can more easily set up debug-logging. Signed-off-by: Sebastiaan van Stijn --- cli-plugins/manager/hooks.go | 55 +++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/cli-plugins/manager/hooks.go b/cli-plugins/manager/hooks.go index b01f63b628ed..0330ba5e238a 100644 --- a/cli-plugins/manager/hooks.go +++ b/cli-plugins/manager/hooks.go @@ -6,6 +6,9 @@ package manager import ( "context" "encoding/json" + "errors" + "fmt" + "strconv" "strings" "github.com/docker/cli/cli-plugins/hooks" @@ -66,47 +69,65 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root pluginDirs := getPluginDirs(cfg) nextSteps := make([]string, 0, len(pluginsCfg)) - for pluginName, pluginCfg := range pluginsCfg { - match, ok := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage) - if !ok { - continue + + tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (messages []string, ok bool, err error) { + match, matched := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage) + if !matched { + return nil, false, nil } p, err := getPlugin(pluginName, pluginDirs, rootCmd) if err != nil { - continue + return nil, false, err } - hookReturn, err := p.RunHook(ctx, HookPluginData{ + resp, err := p.RunHook(ctx, HookPluginData{ RootCmd: match, Flags: flags, CommandError: cmdErrorMessage, }) if err != nil { - // skip misbehaving plugins, but don't halt execution - continue + return nil, false, err } - var hookMessageData hooks.HookMessage - err = json.Unmarshal(hookReturn, &hookMessageData) - if err != nil { - continue + var message hooks.HookMessage + if err := json.Unmarshal(resp, &message); err != nil { + return nil, false, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err) } // currently the only hook type - if hookMessageData.Type != hooks.NextSteps { - continue + if message.Type != hooks.NextSteps { + return nil, false, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type))) } - processedHook, err := hooks.ParseTemplate(hookMessageData.Template, subCmd) + messages, err = hooks.ParseTemplate(message.Template, subCmd) if err != nil { + return nil, false, err + } + + return messages, true, nil + } + + for pluginName, pluginCfg := range pluginsCfg { + messages, ok, err := tryInvokeHook(pluginName, pluginCfg) + if err != nil { + // skip misbehaving plugins, but don't halt execution + logrus.WithFields(logrus.Fields{ + "error": err, + "plugin": pluginName, + }).Debug("Plugin hook invocation failed") + continue + } + if !ok { continue } var appended bool - nextSteps, appended = appendNextSteps(nextSteps, processedHook) + nextSteps, appended = appendNextSteps(nextSteps, messages) if !appended { - logrus.Debugf("Plugin %s responded with an empty hook message %q. Ignoring.", pluginName, string(hookReturn)) + logrus.WithFields(logrus.Fields{ + "plugin": pluginName, + }).Debug("Plugin responded with an empty hook message; ignoring") } } return nextSteps From 60180924e34f3997236df6367b3f56d7238b5459 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 00:35:38 +0100 Subject: [PATCH 04/15] cli-plugins/manager: move HookPluginData to hooks.Request Separate types used by plugins from the manager code. Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hook_types.go | 14 ++++++++++++++ cli-plugins/manager/hooks.go | 16 ++++++---------- cli-plugins/manager/plugin.go | 3 ++- 3 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 cli-plugins/hooks/hook_types.go diff --git a/cli-plugins/hooks/hook_types.go b/cli-plugins/hooks/hook_types.go new file mode 100644 index 000000000000..39d6dd0a780c --- /dev/null +++ b/cli-plugins/hooks/hook_types.go @@ -0,0 +1,14 @@ +package hooks + +// Request is the type representing the information +// that plugins declaring support for hooks get passed when +// being invoked following a CLI command execution. +type Request struct { + // RootCmd is a string representing the matching hook configuration + // which is currently being invoked. If a hook for `docker context` is + // configured and the user executes `docker context ls`, the plugin will + // be invoked with `context`. + RootCmd string + Flags map[string]string + CommandError string +} diff --git a/cli-plugins/manager/hooks.go b/cli-plugins/manager/hooks.go index 0330ba5e238a..1923844515d8 100644 --- a/cli-plugins/manager/hooks.go +++ b/cli-plugins/manager/hooks.go @@ -22,15 +22,11 @@ import ( // HookPluginData is the type representing the information // that plugins declaring support for hooks get passed when // being invoked following a CLI command execution. -type HookPluginData struct { - // RootCmd is a string representing the matching hook configuration - // which is currently being invoked. If a hook for `docker context` is - // configured and the user executes `docker context ls`, the plugin will - // be invoked with `context`. - RootCmd string - Flags map[string]string - CommandError string -} +// +// Deprecated: use [hooks.Request] instead. +// +//go:fix inline +type HookPluginData = hooks.Request // RunCLICommandHooks is the entrypoint into the hooks execution flow after // a main CLI command was executed. It calls the hook subcommand for all @@ -81,7 +77,7 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root return nil, false, err } - resp, err := p.RunHook(ctx, HookPluginData{ + resp, err := p.RunHook(ctx, hooks.Request{ RootCmd: match, Flags: flags, CommandError: cmdErrorMessage, diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go index f5ec6a348f5a..4270bac2ccf8 100644 --- a/cli-plugins/manager/plugin.go +++ b/cli-plugins/manager/plugin.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" + "github.com/docker/cli/cli-plugins/hooks" "github.com/docker/cli/cli-plugins/metadata" "github.com/spf13/cobra" ) @@ -154,7 +155,7 @@ func validateSchemaVersion(version string) error { // RunHook executes the plugin's hooks command // and returns its unprocessed output. -func (p *Plugin) RunHook(ctx context.Context, hookData HookPluginData) ([]byte, error) { +func (p *Plugin) RunHook(ctx context.Context, hookData hooks.Request) ([]byte, error) { hDataBytes, err := json.Marshal(hookData) if err != nil { return nil, wrapAsPluginError(err, "failed to marshall hook data") From 607ebfca5df6b2bffd327657256339173dac57cd Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 00:44:29 +0100 Subject: [PATCH 05/15] cli-plugins/hooks: rename HookMessage to Response Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hook_types.go | 16 ++++++++++++++++ cli-plugins/hooks/template.go | 9 --------- cli-plugins/manager/hooks.go | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/cli-plugins/hooks/hook_types.go b/cli-plugins/hooks/hook_types.go index 39d6dd0a780c..a8ee4f2465ad 100644 --- a/cli-plugins/hooks/hook_types.go +++ b/cli-plugins/hooks/hook_types.go @@ -12,3 +12,19 @@ type Request struct { Flags map[string]string CommandError string } + +// Response represents a plugin hook response. Plugins +// declaring support for CLI hooks need to print a JSON +// representation of this type when their hook subcommand +// is invoked. +type Response struct { + Type HookType + Template string +} + +// HookMessage represents a plugin hook response. +// +// Deprecated: use [Response] instead. +// +//go:fix inline +type HookMessage = Response diff --git a/cli-plugins/hooks/template.go b/cli-plugins/hooks/template.go index e6bd69f38779..7867c2617ff3 100644 --- a/cli-plugins/hooks/template.go +++ b/cli-plugins/hooks/template.go @@ -17,15 +17,6 @@ const ( NextSteps = iota ) -// HookMessage represents a plugin hook response. Plugins -// declaring support for CLI hooks need to print a json -// representation of this type when their hook subcommand -// is invoked. -type HookMessage struct { - Type HookType - Template string -} - // TemplateReplaceSubcommandName returns a hook template string // that will be replaced by the CLI subcommand being executed // diff --git a/cli-plugins/manager/hooks.go b/cli-plugins/manager/hooks.go index 1923844515d8..a58a4439449a 100644 --- a/cli-plugins/manager/hooks.go +++ b/cli-plugins/manager/hooks.go @@ -86,7 +86,7 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root return nil, false, err } - var message hooks.HookMessage + var message hooks.Response if err := json.Unmarshal(resp, &message); err != nil { return nil, false, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err) } From 0431e4d23ca3b7398de8624784c5e94a7825e7d1 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 00:56:44 +0100 Subject: [PATCH 06/15] cli-plugins/hooks: rename HookType to ResponseType Rename the type to match the struct it's used for. Also; - Fix the type of the NextSteps const - Don't use iota for values; the ResponseType is used as part of the "wire" format, which means that plugins using the value can use a different version of the module code; using iota increases the risk of (accidentally) changing values, which would break the wire format. Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hook_types.go | 16 +++++++++++++++- cli-plugins/hooks/template.go | 6 ------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/cli-plugins/hooks/hook_types.go b/cli-plugins/hooks/hook_types.go index a8ee4f2465ad..98a86bf3406a 100644 --- a/cli-plugins/hooks/hook_types.go +++ b/cli-plugins/hooks/hook_types.go @@ -1,5 +1,12 @@ package hooks +// ResponseType is the type of response from the plugin. +type ResponseType int + +const ( + NextSteps ResponseType = 0 +) + // Request is the type representing the information // that plugins declaring support for hooks get passed when // being invoked following a CLI command execution. @@ -18,10 +25,17 @@ type Request struct { // representation of this type when their hook subcommand // is invoked. type Response struct { - Type HookType + Type ResponseType Template string } +// HookType is the type of response from the plugin. +// +// Deprecated: use [ResponseType] instead. +// +//go:fix inline +type HookType = ResponseType + // HookMessage represents a plugin hook response. // // Deprecated: use [Response] instead. diff --git a/cli-plugins/hooks/template.go b/cli-plugins/hooks/template.go index 7867c2617ff3..4dcbb279d49b 100644 --- a/cli-plugins/hooks/template.go +++ b/cli-plugins/hooks/template.go @@ -11,12 +11,6 @@ import ( "github.com/spf13/cobra" ) -type HookType int - -const ( - NextSteps = iota -) - // TemplateReplaceSubcommandName returns a hook template string // that will be replaced by the CLI subcommand being executed // From e26f94d82366fb1c982efc288637bca70cd3f5fa Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 01:04:13 +0100 Subject: [PATCH 07/15] cli-plugins/hooks: add JSON labels, omitzero Add labels to define the expected casing and don't serialize empty fields. Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hook_types.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cli-plugins/hooks/hook_types.go b/cli-plugins/hooks/hook_types.go index 98a86bf3406a..383e58be12c2 100644 --- a/cli-plugins/hooks/hook_types.go +++ b/cli-plugins/hooks/hook_types.go @@ -1,3 +1,6 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.24 + package hooks // ResponseType is the type of response from the plugin. @@ -15,9 +18,9 @@ type Request struct { // which is currently being invoked. If a hook for `docker context` is // configured and the user executes `docker context ls`, the plugin will // be invoked with `context`. - RootCmd string - Flags map[string]string - CommandError string + RootCmd string `json:"RootCmd,omitzero"` + Flags map[string]string `json:"Flags,omitzero"` + CommandError string `json:"CommandError,omitzero"` } // Response represents a plugin hook response. Plugins @@ -25,8 +28,8 @@ type Request struct { // representation of this type when their hook subcommand // is invoked. type Response struct { - Type ResponseType - Template string + Type ResponseType `json:"Type"` + Template string `json:"Template,omitzero"` } // HookType is the type of response from the plugin. From dce201d6ee31cfd13beecb4942a130643b9af8bf Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 01:27:31 +0100 Subject: [PATCH 08/15] cli-plugins/hooks: move template utils separate from render code These utilities are used by CLI-plugins; separate them from the render code, which is used by teh CLI-plugin manager. Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hook_utils.go | 58 +++++++++++++++++++++++++++++++++ cli-plugins/hooks/template.go | 54 ------------------------------ 2 files changed, 58 insertions(+), 54 deletions(-) create mode 100644 cli-plugins/hooks/hook_utils.go diff --git a/cli-plugins/hooks/hook_utils.go b/cli-plugins/hooks/hook_utils.go new file mode 100644 index 000000000000..e80d55ac6168 --- /dev/null +++ b/cli-plugins/hooks/hook_utils.go @@ -0,0 +1,58 @@ +package hooks + +import ( + "fmt" + "strconv" +) + +const ( + hookTemplateCommandName = `{{.Name}}` + hookTemplateFlagValue = `{{flag . "%s"}}` + hookTemplateArg = `{{arg . %s}}` +) + +// TemplateReplaceSubcommandName returns a hook template string +// that will be replaced by the CLI subcommand being executed +// +// Example: +// +// "you ran the subcommand: " + TemplateReplaceSubcommandName() +// +// when being executed after the command: +// `docker run --name "my-container" alpine` +// will result in the message: +// `you ran the subcommand: run` +func TemplateReplaceSubcommandName() string { + return hookTemplateCommandName +} + +// TemplateReplaceFlagValue returns a hook template string +// that will be replaced by the flags value. +// +// Example: +// +// "you ran a container named: " + TemplateReplaceFlagValue("name") +// +// when being executed after the command: +// `docker run --name "my-container" alpine` +// will result in the message: +// `you ran a container named: my-container` +func TemplateReplaceFlagValue(flag string) string { + return fmt.Sprintf(hookTemplateFlagValue, flag) +} + +// TemplateReplaceArg takes an index i and returns a hook +// template string that the CLI will replace the template with +// the ith argument, after processing the passed flags. +// +// Example: +// +// "run this image with `docker run " + TemplateReplaceArg(0) + "`" +// +// when being executed after the command: +// `docker pull alpine` +// will result in the message: +// "Run this image with `docker run alpine`" +func TemplateReplaceArg(i int) string { + return fmt.Sprintf(hookTemplateArg, strconv.Itoa(i)) +} diff --git a/cli-plugins/hooks/template.go b/cli-plugins/hooks/template.go index 4dcbb279d49b..c13a41ff54b1 100644 --- a/cli-plugins/hooks/template.go +++ b/cli-plugins/hooks/template.go @@ -3,60 +3,12 @@ package hooks import ( "bytes" "errors" - "fmt" - "strconv" "strings" "text/template" "github.com/spf13/cobra" ) -// TemplateReplaceSubcommandName returns a hook template string -// that will be replaced by the CLI subcommand being executed -// -// Example: -// -// "you ran the subcommand: " + TemplateReplaceSubcommandName() -// -// when being executed after the command: -// `docker run --name "my-container" alpine` -// will result in the message: -// `you ran the subcommand: run` -func TemplateReplaceSubcommandName() string { - return hookTemplateCommandName -} - -// TemplateReplaceFlagValue returns a hook template string -// that will be replaced by the flags value. -// -// Example: -// -// "you ran a container named: " + TemplateReplaceFlagValue("name") -// -// when being executed after the command: -// `docker run --name "my-container" alpine` -// will result in the message: -// `you ran a container named: my-container` -func TemplateReplaceFlagValue(flag string) string { - return fmt.Sprintf(hookTemplateFlagValue, flag) -} - -// TemplateReplaceArg takes an index i and returns a hook -// template string that the CLI will replace the template with -// the ith argument, after processing the passed flags. -// -// Example: -// -// "run this image with `docker run " + TemplateReplaceArg(0) + "`" -// -// when being executed after the command: -// `docker pull alpine` -// will result in the message: -// "Run this image with `docker run alpine`" -func TemplateReplaceArg(i int) string { - return fmt.Sprintf(hookTemplateArg, strconv.Itoa(i)) -} - func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) { tmpl := template.New("").Funcs(commandFunctions) tmpl, err := tmpl.Parse(hookTemplate) @@ -73,12 +25,6 @@ func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) { var ErrHookTemplateParse = errors.New("failed to parse hook template") -const ( - hookTemplateCommandName = "{{.Name}}" - hookTemplateFlagValue = `{{flag . "%s"}}` - hookTemplateArg = "{{arg . %s}}" -) - var commandFunctions = template.FuncMap{ "flag": getFlagValue, "arg": getArgValue, From aadfe6214fb4b9bda348a9845d336a3a44f98a3c Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 02:20:21 +0100 Subject: [PATCH 09/15] cli-plugins/hooks: update tests - add basic unit-test for the template utilities - make sure the template parsing tests test both the current template produced by the utilities, as well as a fixture - rewrite the printer test to use fixtures - use blackbox testing ("hooks_test") Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hooks_utils_test.go | 50 +++++++++++++++ cli-plugins/hooks/printer_test.go | 31 ++++++---- cli-plugins/hooks/template_test.go | 89 +++++++++++++++++++-------- 3 files changed, 133 insertions(+), 37 deletions(-) create mode 100644 cli-plugins/hooks/hooks_utils_test.go diff --git a/cli-plugins/hooks/hooks_utils_test.go b/cli-plugins/hooks/hooks_utils_test.go new file mode 100644 index 000000000000..1bf5211098c0 --- /dev/null +++ b/cli-plugins/hooks/hooks_utils_test.go @@ -0,0 +1,50 @@ +package hooks_test + +import ( + "testing" + + "github.com/docker/cli/cli-plugins/hooks" +) + +func TestTemplateHelpers(t *testing.T) { + tests := []struct { + doc string + got func() string + want string + }{ + { + doc: "subcommand name", + got: hooks.TemplateReplaceSubcommandName, + want: `{{.Name}}`, + }, + { + doc: "flag value", + got: func() string { + return hooks.TemplateReplaceFlagValue("name") + }, + want: `{{flag . "name"}}`, + }, + { + doc: "arg", + got: func() string { + return hooks.TemplateReplaceArg(0) + }, + want: `{{arg . 0}}`, + }, + { + doc: "arg", + got: func() string { + return hooks.TemplateReplaceArg(3) + }, + want: `{{arg . 3}}`, + }, + } + + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + if got := tc.got(); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} diff --git a/cli-plugins/hooks/printer_test.go b/cli-plugins/hooks/printer_test.go index efe1fe598930..a97d0e75f433 100644 --- a/cli-plugins/hooks/printer_test.go +++ b/cli-plugins/hooks/printer_test.go @@ -1,38 +1,45 @@ -package hooks +package hooks_test import ( - "bytes" + "strings" "testing" - "github.com/morikuni/aec" + "github.com/docker/cli/cli-plugins/hooks" "gotest.tools/v3/assert" ) func TestPrintHookMessages(t *testing.T) { - testCases := []struct { + const header = "\x1b[1m\nWhat's next:\x1b[0m\n" + + tests := []struct { + doc string messages []string expectedOutput string }{ { - messages: []string{}, + doc: "no messages", + messages: nil, expectedOutput: "", }, { + doc: "single message", messages: []string{"Bork!"}, - expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" + + expectedOutput: header + " Bork!\n", }, { + doc: "multiple messages", messages: []string{"Foo", "bar"}, - expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" + + expectedOutput: header + " Foo\n" + " bar\n", }, } - - for _, tc := range testCases { - w := bytes.Buffer{} - PrintNextSteps(&w, tc.messages) - assert.Equal(t, w.String(), tc.expectedOutput) + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + var w strings.Builder + hooks.PrintNextSteps(&w, tc.messages) + assert.Equal(t, w.String(), tc.expectedOutput) + }) } } diff --git a/cli-plugins/hooks/template_test.go b/cli-plugins/hooks/template_test.go index 3154eaf36d3e..28b290696f1d 100644 --- a/cli-plugins/hooks/template_test.go +++ b/cli-plugins/hooks/template_test.go @@ -1,43 +1,67 @@ -package hooks +package hooks_test import ( "testing" + "github.com/docker/cli/cli-plugins/hooks" "github.com/spf13/cobra" "gotest.tools/v3/assert" ) +// TestParseTemplate tests parsing templates as returned by plugins. +// +// It uses fixed string fixtures to lock in compatibility with existing +// plugin templates, so older formats continue to work even if we add new +// template forms. +// +// For helper-backed cases, it also verifies that templates produced by the +// current TemplateReplace* helpers parse to the same output. This lets us +// evolve the emitted template format without breaking older plugins. func TestParseTemplate(t *testing.T) { type testFlag struct { name string value string } - testCases := []struct { - template string + tests := []struct { + doc string + template string // compatibility fixture; keep even if helpers emit a newer form + templateFunc func() string flags []testFlag args []string expectedOutput []string }{ { + doc: "empty template", template: "", expectedOutput: []string{""}, }, { + doc: "plain message", template: "a plain template message", expectedOutput: []string{"a plain template message"}, }, { - template: TemplateReplaceFlagValue("tag"), + doc: "subcommand name", + template: "hello {{.Name}}", // NOTE: fixture; do not modify without considering plugin compatibility + templateFunc: func() string { return "hello " + hooks.TemplateReplaceSubcommandName() }, + + expectedOutput: []string{"hello pull"}, + }, + { + doc: "single flag", + template: `{{flag . "tag"}}`, // NOTE: fixture; do not modify without considering plugin compatibility + templateFunc: func() string { return hooks.TemplateReplaceFlagValue("tag") }, flags: []testFlag{ - { - name: "tag", - value: "my-tag", - }, + {name: "tag", value: "my-tag"}, }, expectedOutput: []string{"my-tag"}, }, { - template: TemplateReplaceFlagValue("test-one") + " " + TemplateReplaceFlagValue("test2"), + doc: "multiple flags", + template: `{{flag . "test-one"}} {{flag . "test2"}}`, // NOTE: fixture; do not modify without considering plugin compatibility + templateFunc: func() string { + return hooks.TemplateReplaceFlagValue("test-one") + " " + hooks.TemplateReplaceFlagValue("test2") + }, flags: []testFlag{ { name: "test-one", @@ -51,36 +75,51 @@ func TestParseTemplate(t *testing.T) { expectedOutput: []string{"value value2"}, }, { - template: TemplateReplaceArg(0) + " " + TemplateReplaceArg(1), + doc: "multiple args", + template: `{{arg . 0}} {{arg . 1}}`, // NOTE: fixture; do not modify without considering plugin compatibility + templateFunc: func() string { return hooks.TemplateReplaceArg(0) + " " + hooks.TemplateReplaceArg(1) }, args: []string{"zero", "one"}, expectedOutput: []string{"zero one"}, }, { - template: "You just pulled " + TemplateReplaceArg(0), + doc: "arg in sentence", + template: "You just pulled {{arg . 0}}", // NOTE: fixture; do not modify without considering plugin compatibility + templateFunc: func() string { return "You just pulled " + hooks.TemplateReplaceArg(0) }, args: []string{"alpine"}, expectedOutput: []string{"You just pulled alpine"}, }, { + doc: "multiline output", template: "one line\nanother line!", expectedOutput: []string{"one line", "another line!"}, }, } - for _, tc := range testCases { - testCmd := &cobra.Command{ - Use: "pull", - Args: cobra.ExactArgs(len(tc.args)), - } - for _, f := range tc.flags { - _ = testCmd.Flags().String(f.name, "", "") - err := testCmd.Flag(f.name).Value.Set(f.value) + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + testCmd := &cobra.Command{ + Use: "pull", + Args: cobra.ExactArgs(len(tc.args)), + } + for _, f := range tc.flags { + _ = testCmd.Flags().String(f.name, "", "") + err := testCmd.Flag(f.name).Value.Set(f.value) + assert.NilError(t, err) + } + err := testCmd.Flags().Parse(tc.args) + assert.NilError(t, err) + + // Validate using fixtures. + out, err := hooks.ParseTemplate(tc.template, testCmd) assert.NilError(t, err) - } - err := testCmd.Flags().Parse(tc.args) - assert.NilError(t, err) + assert.DeepEqual(t, out, tc.expectedOutput) - out, err := ParseTemplate(tc.template, testCmd) - assert.NilError(t, err) - assert.DeepEqual(t, out, tc.expectedOutput) + if tc.templateFunc != nil { + // Validate using the current template function equivalent. + out, err = hooks.ParseTemplate(tc.templateFunc(), testCmd) + assert.NilError(t, err) + assert.DeepEqual(t, out, tc.expectedOutput) + } + }) } } From cd053606a6ee9e98d2e49b765963e498d815f63e Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 13 Mar 2026 21:56:59 +0100 Subject: [PATCH 10/15] cli-plugins/hooks: slight tweaks in templates - use `%q` instead of manually quoting the string - use `%d` instead of manually converting the number to a string Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hook_utils.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cli-plugins/hooks/hook_utils.go b/cli-plugins/hooks/hook_utils.go index e80d55ac6168..0bf6a437108a 100644 --- a/cli-plugins/hooks/hook_utils.go +++ b/cli-plugins/hooks/hook_utils.go @@ -2,13 +2,12 @@ package hooks import ( "fmt" - "strconv" ) const ( hookTemplateCommandName = `{{.Name}}` - hookTemplateFlagValue = `{{flag . "%s"}}` - hookTemplateArg = `{{arg . %s}}` + hookTemplateFlagValue = `{{flag . %q}}` + hookTemplateArg = `{{arg . %d}}` ) // TemplateReplaceSubcommandName returns a hook template string @@ -54,5 +53,5 @@ func TemplateReplaceFlagValue(flag string) string { // will result in the message: // "Run this image with `docker run alpine`" func TemplateReplaceArg(i int) string { - return fmt.Sprintf(hookTemplateArg, strconv.Itoa(i)) + return fmt.Sprintf(hookTemplateArg, i) } From 4142d4026e53f07d8f0892dce64ebdeecd28c02d Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 11:35:23 +0100 Subject: [PATCH 11/15] cli-plugins/hooks: detect if templating is needed Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/template.go | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/cli-plugins/hooks/template.go b/cli-plugins/hooks/template.go index c13a41ff54b1..105adec192a2 100644 --- a/cli-plugins/hooks/template.go +++ b/cli-plugins/hooks/template.go @@ -10,17 +10,22 @@ import ( ) func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) { - tmpl := template.New("").Funcs(commandFunctions) - tmpl, err := tmpl.Parse(hookTemplate) - if err != nil { - return nil, err + out := hookTemplate + if strings.Contains(hookTemplate, "{{") { + // Message may be a template. + tmpl := template.New("").Funcs(commandFunctions) + tmpl, err := tmpl.Parse(hookTemplate) + if err != nil { + return nil, err + } + var b bytes.Buffer + err = tmpl.Execute(&b, cmd) + if err != nil { + return nil, err + } + out = b.String() } - b := bytes.Buffer{} - err = tmpl.Execute(&b, cmd) - if err != nil { - return nil, err - } - return strings.Split(b.String(), "\n"), nil + return strings.Split(out, "\n"), nil } var ErrHookTemplateParse = errors.New("failed to parse hook template") From 4a1b2ef2c5d2f3ee4f5988f6fccde2fdf5764155 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 10 Mar 2026 10:46:02 +0100 Subject: [PATCH 12/15] cli-plugins/hooks: update godoc Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hook_types.go | 50 +++++++++++++++++++++++++++---- cli-plugins/hooks/hook_utils.go | 52 ++++++++++++++++++++++----------- cli-plugins/hooks/printer.go | 2 ++ 3 files changed, 81 insertions(+), 23 deletions(-) diff --git a/cli-plugins/hooks/hook_types.go b/cli-plugins/hooks/hook_types.go index 383e58be12c2..02cd93c8f1b7 100644 --- a/cli-plugins/hooks/hook_types.go +++ b/cli-plugins/hooks/hook_types.go @@ -1,6 +1,29 @@ // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: //go:build go1.24 +// Package hooks defines the contract between the Docker CLI and CLI plugin hook +// implementations. +// +// # Audience +// +// This package is intended to be imported by CLI plugin implementations that +// implement a "hooks" subcommand, and by the Docker CLI when invoking those +// hooks. +// +// # Contract and wire format +// +// Hook inputs (see [Request]) are serialized as JSON and passed to the plugin hook +// subcommand (currently as a command-line argument). Hook outputs are emitted by +// the plugin as JSON (see [Response]). +// +// # Stability +// +// The types that represent the hook contract ([Request], [Response] and related +// constants) are considered part of Docker CLI's public Go API. +// Fields and values may be extended in a backwards-compatible way (for example, +// adding new fields), but existing fields and their meaning should remain stable. +// Plugins should ignore unknown fields and unknown hook types to remain +// forwards-compatible. package hooks // ResponseType is the type of response from the plugin. @@ -15,12 +38,27 @@ const ( // being invoked following a CLI command execution. type Request struct { // RootCmd is a string representing the matching hook configuration - // which is currently being invoked. If a hook for `docker context` is - // configured and the user executes `docker context ls`, the plugin will - // be invoked with `context`. - RootCmd string `json:"RootCmd,omitzero"` - Flags map[string]string `json:"Flags,omitzero"` - CommandError string `json:"CommandError,omitzero"` + // which is currently being invoked. If a hook for "docker context" + // is configured and the user executes "docker context ls", the plugin + // is invoked with "context". + RootCmd string `json:"RootCmd,omitzero"` + + // Flags contains flags that were set on the command for which the + // hook was invoked. It uses flag names as key, with leading hyphens + // removed ("--flag" and "-flag" are included as "flag" and "f"). + // + // Flag values are not included and are set to an empty string, + // except for boolean flags known to the CLI itself, for which + // the value is either "true", or "false". + // + // Plugins can use this information to adjust their [Response] + // based on whether the command triggering the hook was invoked + // with. + Flags map[string]string `json:"Flags,omitzero"` + + // CommandError is a string containing the error output (if any) + // of the command for which the hook was invoked. + CommandError string `json:"CommandError,omitzero"` } // Response represents a plugin hook response. Plugins diff --git a/cli-plugins/hooks/hook_utils.go b/cli-plugins/hooks/hook_utils.go index 0bf6a437108a..7848110dd0dd 100644 --- a/cli-plugins/hooks/hook_utils.go +++ b/cli-plugins/hooks/hook_utils.go @@ -15,43 +15,61 @@ const ( // // Example: // -// "you ran the subcommand: " + TemplateReplaceSubcommandName() +// Response{ +// Type: NextSteps, +// Template: "you ran the subcommand: " + TemplateReplaceSubcommandName(), +// } // -// when being executed after the command: -// `docker run --name "my-container" alpine` -// will result in the message: -// `you ran the subcommand: run` +// When being executed after the command: +// +// docker run --name "my-container" alpine +// +// It results in the message: +// +// you ran the subcommand: run func TemplateReplaceSubcommandName() string { return hookTemplateCommandName } -// TemplateReplaceFlagValue returns a hook template string -// that will be replaced by the flags value. +// TemplateReplaceFlagValue returns a hook template string that will be +// replaced with the flags value when printed by the CLI. // // Example: // -// "you ran a container named: " + TemplateReplaceFlagValue("name") +// Response{ +// Type: NextSteps, +// Template: "you ran a container named: " + TemplateReplaceFlagValue("name"), +// } // -// when being executed after the command: -// `docker run --name "my-container" alpine` -// will result in the message: -// `you ran a container named: my-container` +// when executed after the command: +// +// docker run --name "my-container" alpine +// +// it results in the message: +// +// you ran a container named: my-container func TemplateReplaceFlagValue(flag string) string { return fmt.Sprintf(hookTemplateFlagValue, flag) } // TemplateReplaceArg takes an index i and returns a hook // template string that the CLI will replace the template with -// the ith argument, after processing the passed flags. +// the ith argument after processing the passed flags. // // Example: // -// "run this image with `docker run " + TemplateReplaceArg(0) + "`" +// Response{ +// Type: NextSteps, +// Template: "run this image with `docker run " + TemplateReplaceArg(0) + "`", +// } // // when being executed after the command: -// `docker pull alpine` -// will result in the message: -// "Run this image with `docker run alpine`" +// +// docker pull alpine +// +// It results in the message: +// +// Run this image with `docker run alpine` func TemplateReplaceArg(i int) string { return fmt.Sprintf(hookTemplateArg, i) } diff --git a/cli-plugins/hooks/printer.go b/cli-plugins/hooks/printer.go index f6d4b28ef488..03355f20897d 100644 --- a/cli-plugins/hooks/printer.go +++ b/cli-plugins/hooks/printer.go @@ -7,6 +7,8 @@ import ( "github.com/morikuni/aec" ) +// PrintNextSteps renders list of [NextSteps] messages and writes them +// to out. It is a no-op if messages is empty. func PrintNextSteps(out io.Writer, messages []string) { if len(messages) == 0 { return From 924324034680ef8c301aef6f500e9c562866e974 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 09:09:05 +0100 Subject: [PATCH 13/15] cli-plugins/hooks: add commandInfo type for templating Define a local type for methods to expose to the template, instead of passing the cobra.Cmd. This avoids templates depending on features exposed by Cobra that are not part of the contract, and slightly decouples the templat from the Cobra implementation. Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hook_utils.go | 4 +- cli-plugins/hooks/hooks_utils_test.go | 6 +-- cli-plugins/hooks/template.go | 58 +++++++++++++++++++-------- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/cli-plugins/hooks/hook_utils.go b/cli-plugins/hooks/hook_utils.go index 7848110dd0dd..a6345cc1d5a6 100644 --- a/cli-plugins/hooks/hook_utils.go +++ b/cli-plugins/hooks/hook_utils.go @@ -6,8 +6,8 @@ import ( const ( hookTemplateCommandName = `{{.Name}}` - hookTemplateFlagValue = `{{flag . %q}}` - hookTemplateArg = `{{arg . %d}}` + hookTemplateFlagValue = `{{.FlagValue %q}}` + hookTemplateArg = `{{.Arg %d}}` ) // TemplateReplaceSubcommandName returns a hook template string diff --git a/cli-plugins/hooks/hooks_utils_test.go b/cli-plugins/hooks/hooks_utils_test.go index 1bf5211098c0..ba9ad881b4ef 100644 --- a/cli-plugins/hooks/hooks_utils_test.go +++ b/cli-plugins/hooks/hooks_utils_test.go @@ -22,21 +22,21 @@ func TestTemplateHelpers(t *testing.T) { got: func() string { return hooks.TemplateReplaceFlagValue("name") }, - want: `{{flag . "name"}}`, + want: `{{.FlagValue "name"}}`, }, { doc: "arg", got: func() string { return hooks.TemplateReplaceArg(0) }, - want: `{{arg . 0}}`, + want: `{{.Arg 0}}`, }, { doc: "arg", got: func() string { return hooks.TemplateReplaceArg(3) }, - want: `{{arg . 3}}`, + want: `{{.Arg 3}}`, }, } diff --git a/cli-plugins/hooks/template.go b/cli-plugins/hooks/template.go index 105adec192a2..c92c16d21bb3 100644 --- a/cli-plugins/hooks/template.go +++ b/cli-plugins/hooks/template.go @@ -3,6 +3,7 @@ package hooks import ( "bytes" "errors" + "fmt" "strings" "text/template" @@ -13,13 +14,18 @@ func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) { out := hookTemplate if strings.Contains(hookTemplate, "{{") { // Message may be a template. - tmpl := template.New("").Funcs(commandFunctions) - tmpl, err := tmpl.Parse(hookTemplate) + msgContext := commandInfo{cmd: cmd} + + tmpl, err := template.New("").Funcs(template.FuncMap{ + // kept for backward-compatibility with old templates. + "flag": func(_ any, flagName string) (string, error) { return msgContext.FlagValue(flagName) }, + "arg": func(_ any, i int) (string, error) { return msgContext.Arg(i) }, + }).Parse(hookTemplate) if err != nil { return nil, err } var b bytes.Buffer - err = tmpl.Execute(&b, cmd) + err = tmpl.Execute(&b, msgContext) if err != nil { return nil, err } @@ -30,23 +36,43 @@ func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) { var ErrHookTemplateParse = errors.New("failed to parse hook template") -var commandFunctions = template.FuncMap{ - "flag": getFlagValue, - "arg": getArgValue, +// commandInfo provides info about the command for which the hook was invoked. +// It is used for templated hook-messages. +type commandInfo struct { + cmd *cobra.Command +} + +// Name returns the name of the (sub)command for which the hook was invoked. +// +// It's used for backward-compatibility with old templates. +func (c commandInfo) Name() string { + if c.cmd == nil { + return "" + } + return c.cmd.Name() } -func getFlagValue(cmd *cobra.Command, flag string) (string, error) { - cmdFlag := cmd.Flag(flag) - if cmdFlag == nil { - return "", ErrHookTemplateParse +// FlagValue returns the value that was set for the given flag when the hook was invoked. +func (c commandInfo) FlagValue(flagName string) (string, error) { + if c.cmd == nil { + return "", fmt.Errorf("%w: flagValue: cmd is nil", ErrHookTemplateParse) + } + f := c.cmd.Flag(flagName) + if f == nil { + return "", fmt.Errorf("%w: flagValue: no flags found", ErrHookTemplateParse) } - return cmdFlag.Value.String(), nil + return f.Value.String(), nil } -func getArgValue(cmd *cobra.Command, i int) (string, error) { - flags := cmd.Flags() - if flags == nil { - return "", ErrHookTemplateParse +// Arg returns the value of the nth argument. +func (c commandInfo) Arg(n int) (string, error) { + if c.cmd == nil { + return "", fmt.Errorf("%w: arg: cmd is nil", ErrHookTemplateParse) + } + flags := c.cmd.Flags() + v := flags.Arg(n) + if v == "" && n >= flags.NArg() { + return "", fmt.Errorf("%w: arg: %dth argument not set", ErrHookTemplateParse, n) } - return flags.Arg(i), nil + return v, nil } From dd1f7f58565dd36b032dfbee3b287c2ac31ee152 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 10:00:05 +0100 Subject: [PATCH 14/15] cli-plugins/hooks: simplify templating formats This allows for slighly cleaner / more natural placeholders, as it doesn't require the context (`.`) to be specified; - `{{command}}` instead of `{{.Command}}` or `{{command .}}` - `{{flagValue "my-flag"}}` instead of `{{.FlagValue "my-flag"}} or `{{flagValue . "my-flag"}}` Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hook_utils.go | 6 +++--- cli-plugins/hooks/hooks_utils_test.go | 8 ++++---- cli-plugins/hooks/template.go | 21 +++++++++++++++------ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/cli-plugins/hooks/hook_utils.go b/cli-plugins/hooks/hook_utils.go index a6345cc1d5a6..c6babbe469a9 100644 --- a/cli-plugins/hooks/hook_utils.go +++ b/cli-plugins/hooks/hook_utils.go @@ -5,9 +5,9 @@ import ( ) const ( - hookTemplateCommandName = `{{.Name}}` - hookTemplateFlagValue = `{{.FlagValue %q}}` - hookTemplateArg = `{{.Arg %d}}` + hookTemplateCommandName = `{{command}}` + hookTemplateFlagValue = `{{flagValue %q}}` + hookTemplateArg = `{{argValue %d}}` ) // TemplateReplaceSubcommandName returns a hook template string diff --git a/cli-plugins/hooks/hooks_utils_test.go b/cli-plugins/hooks/hooks_utils_test.go index ba9ad881b4ef..877909109cc7 100644 --- a/cli-plugins/hooks/hooks_utils_test.go +++ b/cli-plugins/hooks/hooks_utils_test.go @@ -15,28 +15,28 @@ func TestTemplateHelpers(t *testing.T) { { doc: "subcommand name", got: hooks.TemplateReplaceSubcommandName, - want: `{{.Name}}`, + want: `{{command}}`, }, { doc: "flag value", got: func() string { return hooks.TemplateReplaceFlagValue("name") }, - want: `{{.FlagValue "name"}}`, + want: `{{flagValue "name"}}`, }, { doc: "arg", got: func() string { return hooks.TemplateReplaceArg(0) }, - want: `{{.Arg 0}}`, + want: `{{argValue 0}}`, }, { doc: "arg", got: func() string { return hooks.TemplateReplaceArg(3) }, - want: `{{.Arg 3}}`, + want: `{{argValue 3}}`, }, } diff --git a/cli-plugins/hooks/template.go b/cli-plugins/hooks/template.go index c92c16d21bb3..ba77dd3f6840 100644 --- a/cli-plugins/hooks/template.go +++ b/cli-plugins/hooks/template.go @@ -17,9 +17,13 @@ func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) { msgContext := commandInfo{cmd: cmd} tmpl, err := template.New("").Funcs(template.FuncMap{ + "command": msgContext.command, + "flagValue": msgContext.flagValue, + "argValue": msgContext.argValue, + // kept for backward-compatibility with old templates. - "flag": func(_ any, flagName string) (string, error) { return msgContext.FlagValue(flagName) }, - "arg": func(_ any, i int) (string, error) { return msgContext.Arg(i) }, + "flag": func(_ any, flagName string) (string, error) { return msgContext.flagValue(flagName) }, + "arg": func(_ any, i int) (string, error) { return msgContext.argValue(i) }, }).Parse(hookTemplate) if err != nil { return nil, err @@ -46,14 +50,19 @@ type commandInfo struct { // // It's used for backward-compatibility with old templates. func (c commandInfo) Name() string { + return c.command() +} + +// command returns the name of the (sub)command for which the hook was invoked. +func (c commandInfo) command() string { if c.cmd == nil { return "" } return c.cmd.Name() } -// FlagValue returns the value that was set for the given flag when the hook was invoked. -func (c commandInfo) FlagValue(flagName string) (string, error) { +// flagValue returns the value that was set for the given flag when the hook was invoked. +func (c commandInfo) flagValue(flagName string) (string, error) { if c.cmd == nil { return "", fmt.Errorf("%w: flagValue: cmd is nil", ErrHookTemplateParse) } @@ -64,8 +73,8 @@ func (c commandInfo) FlagValue(flagName string) (string, error) { return f.Value.String(), nil } -// Arg returns the value of the nth argument. -func (c commandInfo) Arg(n int) (string, error) { +// argValue returns the value of the nth argument. +func (c commandInfo) argValue(n int) (string, error) { if c.cmd == nil { return "", fmt.Errorf("%w: arg: cmd is nil", ErrHookTemplateParse) } From 4bf4d567bd2eedd18a6622cc2b859bec15032e33 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 18 Mar 2026 15:06:54 +0100 Subject: [PATCH 15/15] cli-plugins/hooks: PrintNextSteps: slight cleanup - skip aec to construct the formatting and use a const instead - skip fmt.Println and write directly to the writer - move newlines outside of the "bold" formatting Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/printer.go | 17 ++++++++++------- cli-plugins/hooks/printer_test.go | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cli-plugins/hooks/printer.go b/cli-plugins/hooks/printer.go index 03355f20897d..c9b0bd9bcd9f 100644 --- a/cli-plugins/hooks/printer.go +++ b/cli-plugins/hooks/printer.go @@ -1,10 +1,10 @@ package hooks -import ( - "fmt" - "io" +import "io" - "github.com/morikuni/aec" +const ( + whatsNext = "\n\033[1mWhat's next:\033[0m\n" + indent = " " ) // PrintNextSteps renders list of [NextSteps] messages and writes them @@ -13,8 +13,11 @@ func PrintNextSteps(out io.Writer, messages []string) { if len(messages) == 0 { return } - _, _ = fmt.Fprintln(out, aec.Bold.Apply("\nWhat's next:")) - for _, n := range messages { - _, _ = fmt.Fprintln(out, " ", n) + + _, _ = io.WriteString(out, whatsNext) + for _, msg := range messages { + _, _ = io.WriteString(out, indent) + _, _ = io.WriteString(out, msg) + _, _ = io.WriteString(out, "\n") } } diff --git a/cli-plugins/hooks/printer_test.go b/cli-plugins/hooks/printer_test.go index a97d0e75f433..8b1ca9234ed8 100644 --- a/cli-plugins/hooks/printer_test.go +++ b/cli-plugins/hooks/printer_test.go @@ -9,7 +9,7 @@ import ( ) func TestPrintHookMessages(t *testing.T) { - const header = "\x1b[1m\nWhat's next:\x1b[0m\n" + const header = "\n\x1b[1mWhat's next:\x1b[0m\n" tests := []struct { doc string