Repository: helm/helm (go)

Issue:
Windows support for Helm plugin installers
Helm's plugin install hook system relies on `/bin/sh`, regardless of the operating system present. Windows users can work around this by using Helm under WSL, but it would be great to have true Windows support out of the box.

Example use case as demonstrated in https://github.com/helm/helm-2to3/issues/55:

```
$ helm3 plugin install https://github.com/helm/helm-2to3 --debug
[debug] updating https://github.com/helm/helm-2to3
[debug] symlinking C:\Users\nlowe\AppData\Local\Temp\helm\plugins\https-github.com-helm-helm-2to3 to C:\Users\nlowe\AppData\Roaming\helm\plugins\helm-2to3
plugin_install.go:75: [debug] loading plugin from C:\Users\nlowe\AppData\Roaming\helm\plugins\helm-2to3
plugin.go:60: [debug] running install hook: &{sh [sh -c cd $HELM_PLUGIN_DIR; scripts/install_plugin.sh] []  <nil> <nil> <nil> [] %!s(*syscall.SysProcAttr=<nil>) %!s(*os.Process=<nil>) <nil> <nil> %!s(*exec.Error=&{sh 0xc00008d5c0}) %!s(bool=false) [] [] [] [] %!s(chan error=<nil>) %!s(chan struct {}=<nil>)}
Error: exec: "sh": executable file not found in %PATH%
helm.go:81: [debug] exec: "sh": executable file not found in %PATH%
```

Pull request description:
feat: Added multi-platform plugin hook support
<!--  Thanks for sending a pull request!  Here are some tips for you:
1. Make sure to read the Contributing Guide before submitting your PR: https://github.com/helm/helm/blob/main/CONTRIBUTING.md
2. If this PR closes another issue, add 'closes #<issue number>' somewhere in the PR summary. GitHub will automatically close that issue when this PR gets merged. Alternatively, adding 'refs #<issue number>' will not close the issue, but help provide the reviewer more context.-->

**What this PR does / why we need it**:

This PR addresses #7117 to allow plugin installation on Windows in a backwards compatible way. It also adds better OS specific hook support and improved platform command logic to support args containing a space.

I've forked `helm-diff` at https://github.com/stevehipwell/helm-diff to provide an example of how this works,

**Special notes for your reviewer**:

- Closes #7117
- Docs PR https://github.com/helm/helm-www/pull/1627

**If applicable**:
- [ ] this PR contains documentation
- [x] this PR contains unit tests
- [x] this PR has been tested for backwards compatibility


Patch:
diff --git a/cmd/helm/plugin.go b/cmd/helm/plugin.go
index 8e1044f54..27ed70727 100644
--- a/cmd/helm/plugin.go
+++ b/cmd/helm/plugin.go
@@ -47,19 +47,27 @@ func newPluginCmd(out io.Writer) *cobra.Command {
 
 // runHook will execute a plugin hook.
 func runHook(p *plugin.Plugin, event string) error {
-	hook := p.Metadata.Hooks[event]
-	if hook == "" {
+	plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir)
+
+	cmds := p.Metadata.PlatformHooks[event]
+	expandArgs := true
+	if len(cmds) == 0 && len(p.Metadata.Hooks) > 0 {
+		cmd := p.Metadata.Hooks[event]
+		if len(cmd) > 0 {
+			cmds = []plugin.PlatformCommand{{Command: "sh", Args: []string{"-c", cmd}}}
+			expandArgs = false
+		}
+	}
+
+	main, argv, err := plugin.PrepareCommands(cmds, expandArgs, []string{})
+	if err != nil {
 		return nil
 	}
 
-	prog := exec.Command("sh", "-c", hook)
-	// TODO make this work on windows
-	// I think its ... ¯\_(ツ)_/¯
-	// prog := exec.Command("cmd", "/C", p.Metadata.Hooks.Install())
+	prog := exec.Command(main, argv...)
 
 	debug("running %s hook: %s", event, prog)
 
-	plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir)
 	prog.Stdout, prog.Stderr = os.Stdout, os.Stderr
 	if err := prog.Run(); err != nil {
 		if eerr, ok := err.(*exec.ExitError); ok {
diff --git a/pkg/plugin/hooks.go b/pkg/plugin/hooks.go
index e3481515f..34d3163a4 100644
--- a/pkg/plugin/hooks.go
+++ b/pkg/plugin/hooks.go
@@ -25,5 +25,8 @@ const (
 	Update = "update"
 )
 
+// PlatformHooks is a map of events to a command for a particular operating system and architecture.
+type PlatformHooks map[string][]PlatformCommand
+
 // Hooks is a map of events to commands.
 type Hooks map[string]string
diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go
index 5bb743481..8eac1f74d 100644
--- a/pkg/plugin/plugin.go
+++ b/pkg/plugin/plugin.go
@@ -44,9 +44,10 @@ type Downloaders struct {
 
 // PlatformCommand represents a command for a particular operating system and architecture
 type PlatformCommand struct {
-	OperatingSystem string `json:"os"`
-	Architecture    string `json:"arch"`
-	Command         string `json:"command"`
+	OperatingSystem string   `json:"os"`
+	Architecture    string   `json:"arch"`
+	Command         string   `json:"command"`
+	Args            []string `json:"args"`
 }
 
 // Metadata describes a plugin.
@@ -65,23 +66,35 @@ type Metadata struct {
 	// Description is a long description shown in places like `helm help`
 	Description string `json:"description"`
 
-	// Command is the command, as a single string.
+	// PlatformCommand is the plugin command, with a platform selector and support for args.
 	//
-	// The command will be passed through environment expansion, so env vars can
+	// The command and args will be passed through environment expansion, so env vars can
 	// be present in this command. Unless IgnoreFlags is set, this will
 	// also merge the flags passed from Helm.
 	//
-	// Note that command is not executed in a shell. To do so, we suggest
+	// Note that the command is not executed in a shell. To do so, we suggest
 	// pointing the command to a shell script.
 	//
-	// The following rules will apply to processing commands:
-	// - If platformCommand is present, it will be searched first
+	// The following rules will apply to processing platform commands:
+	// - If PlatformCommand is present, it will be used
 	// - If both OS and Arch match the current platform, search will stop and the command will be executed
-	// - If OS matches and there is no more specific match, the command will be executed
+	// - If OS matches and Arch is empty, the command will be executed
 	// - If no OS/Arch match is found, the default command will be executed
-	// - If no command is present and no matches are found in platformCommand, Helm will exit with an error
+	// - If no matches are found in platformCommand, Helm will exit with an error
 	PlatformCommand []PlatformCommand `json:"platformCommand"`
-	Command         string            `json:"command"`
+
+	// Command is the plugin command, as a single string.
+	// Providing a command will result in an error if PlatformCommand is also set.
+	//
+	// The command will be passed through environment expansion, so env vars can
+	// be present in this command. Unless IgnoreFlags is set, this will
+	// also merge the flags passed from Helm.
+	//
+	// Note that command is not executed in a shell. To do so, we suggest
+	// pointing the command to a shell script.
+	//
+	// DEPRECATED: Use PlatformCommand instead. Remove in Helm 4.
+	Command string `json:"command"`
 
 	// IgnoreFlags ignores any flags passed in from Helm
 	//
@@ -90,7 +103,31 @@ type Metadata struct {
 	// the `--debug` flag will be discarded.
 	IgnoreFlags bool `json:"ignoreFlags"`
 
-	// Hooks are commands that will run on events.
+	// PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
+	//
+	// The command and args will be passed through environment expansion, so env vars can
+	// be present in the command.
+	//
+	// Note that the command is not executed in a shell. To do so, we suggest
+	// pointing the command to a shell script.
+	//
+	// The following rules will apply to processing platform hooks:
+	// - If PlatformHooks is present, it will be used
+	// - If both OS and Arch match the current platform, search will stop and the command will be executed
+	// - If OS matches and Arch is empty, the command will be executed
+	// - If no OS/Arch match is found, the default command will be executed
+	// - If no matches are found in platformHooks, Helm will skip the event
+	PlatformHooks PlatformHooks `json:"platformHooks"`
+
+	// Hooks are commands that will run on plugin events, as a single string.
+	// Providing a hooks will result in an error if PlatformHooks is also set.
+	//
+	// The command will be passed through environment expansion, so env vars can
+	// be present in this command.
+	//
+	// Note that the command is executed in the sh shell.
+	//
+	// DEPRECATED: Use PlatformHooks instead. Remove in Helm 4.
 	Hooks Hooks
 
 	// Downloaders field is used if the plugin supply downloader mechanism
@@ -112,62 +149,106 @@ type Plugin struct {
 	Dir string
 }
 
-// The following rules will apply to processing the Plugin.PlatformCommand.Command:
-// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
-// - If OS matches and there is no more specific match, the command will be prepared for execution
-// - If no OS/Arch match is found, return nil
-func getPlatformCommand(cmds []PlatformCommand) []string {
-	var command []string
+// Returns command and args strings based on the following rules in priority order:
+// - From the PlatformCommand where OS and Arch match the current platform
+// - From the PlatformCommand where OS matches the current platform and Arch is empty/unspecified
+// - From the PlatformCommand where OS is empty/unspecified and Arch matches the current platform
+// - From the PlatformCommand where OS and Arch are both empty/unspecified
+// - Return nil, nil
+func getPlatformCommand(cmds []PlatformCommand) ([]string, []string) {
+	var command, args []string
+	found := false
+	foundOs := false
+
 	eq := strings.EqualFold
 	for _, c := range cmds {
-		if eq(c.OperatingSystem, runtime.GOOS) {
-			command = strings.Split(c.Command, " ")
-		}
 		if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) {
-			return strings.Split(c.Command, " ")
+			// Return early for an exact match
+			return strings.Split(c.Command, " "), c.Args
+		}
+
+		if (len(c.OperatingSystem) > 0 && !eq(c.OperatingSystem, runtime.GOOS)) || len(c.Architecture) > 0 {
+			// Skip if OS is not empty and doesn't match or if arch is set as a set arch requires an OS match
+			continue
+		}
+
+		if !foundOs && len(c.OperatingSystem) > 0 && eq(c.OperatingSystem, runtime.GOOS) {
+			// First OS match with empty arch, can only be overridden by a direct match
+			command = strings.Split(c.Command, " ")
+			args = c.Args
+			found = true
+			foundOs = true
+		} else if !found {
+			// First empty match, can be overridden by a direct match or an OS match
+			command = strings.Split(c.Command, " ")
+			args = c.Args
+			found = true
 		}
 	}
-	return command
+
+	return command, args
 }
 
-// PrepareCommand takes a Plugin.PlatformCommand.Command, a Plugin.Command and will applying the following processing:
-// - If platformCommand is present, it will be searched first
-// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
-// - If OS matches and there is no more specific match, the command will be prepared for execution
-// - If no OS/Arch match is found, the default command will be prepared for execution
-// - If no command is present and no matches are found in platformCommand, will exit with an error
+// PrepareCommands takes a []Plugin.PlatformCommand
+// and prepares the command and arguments for execution.
 //
 // It merges extraArgs into any arguments supplied in the plugin. It
-// returns the name of the command and an args array.
+// returns the main command and an args array.
 //
 // The result is suitable to pass to exec.Command.
-func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) {
-	var parts []string
-	platCmdLen := len(p.Metadata.PlatformCommand)
-	if platCmdLen > 0 {
-		parts = getPlatformCommand(p.Metadata.PlatformCommand)
-	}
-	if platCmdLen == 0 || parts == nil {
-		parts = strings.Split(p.Metadata.Command, " ")
-	}
-	if len(parts) == 0 || parts[0] == "" {
+func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string) (string, []string, error) {
+	cmdParts, args := getPlatformCommand(cmds)
+	if len(cmdParts) == 0 || cmdParts[0] == "" {
 		return "", nil, fmt.Errorf("no plugin command is applicable")
 	}
 
-	main := os.ExpandEnv(parts[0])
+	main := os.ExpandEnv(cmdParts[0])
 	baseArgs := []string{}
-	if len(parts) > 1 {
-		for _, cmdpart := range parts[1:] {
-			cmdexp := os.ExpandEnv(cmdpart)
-			baseArgs = append(baseArgs, cmdexp)
+	if len(cmdParts) > 1 {
+		for _, cmdPart := range cmdParts[1:] {
+			if expandArgs {
+				baseArgs = append(baseArgs, os.ExpandEnv(cmdPart))
+			} else {
+				baseArgs = append(baseArgs, cmdPart)
+			}
 		}
 	}
-	if !p.Metadata.IgnoreFlags {
+
+	for _, arg := range args {
+		if expandArgs {
+			baseArgs = append(baseArgs, os.ExpandEnv(arg))
+		} else {
+			baseArgs = append(baseArgs, arg)
+		}
+	}
+
+	if len(extraArgs) > 0 {
 		baseArgs = append(baseArgs, extraArgs...)
 	}
+
 	return main, baseArgs, nil
 }
 
+// PrepareCommand gets the correct command and arguments for a plugin.
+//
+// It merges extraArgs into any arguments supplied in the plugin. It returns the name of the command and an args array.
+//
+// The result is suitable to pass to exec.Command.
+func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) {
+	var extraArgsIn []string
+
+	if !p.Metadata.IgnoreFlags {
+		extraArgsIn = extraArgs
+	}
+
+	cmds := p.Metadata.PlatformCommand
+	if len(cmds) == 0 && len(p.Metadata.Command) > 0 {
+		cmds = []PlatformCommand{{Command: p.Metadata.Command}}
+	}
+
+	return PrepareCommands(cmds, true, extraArgsIn)
+}
+
 // validPluginName is a regular expression that validates plugin names.
 //
 // Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, ​_​ and ​-.
@@ -184,6 +265,14 @@ func validatePluginData(plug *Plugin, filepath string) error {
 	}
 	plug.Metadata.Usage = sanitizeString(plug.Metadata.Usage)
 
+	if len(plug.Metadata.PlatformCommand) > 0 && len(plug.Metadata.Command) > 0 {
+		return fmt.Errorf("both platformCommand and command are set in %q", filepath)
+	}
+
+	if len(plug.Metadata.PlatformHooks) > 0 && len(plug.Metadata.Hooks) > 0 {
+		return fmt.Errorf("both platformHooks and hooks are set in %q", filepath)
+	}
+
 	// We could also validate SemVer, executable, and other fields should we so choose.
 	return nil
 }


Test patch:
diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go
index b81797390..770d701fa 100644
--- a/pkg/plugin/plugin_test.go
+++ b/pkg/plugin/plugin_test.go
@@ -26,163 +26,256 @@ import (
 	"helm.sh/helm/v3/pkg/cli"
 )
 
-func checkCommand(p *Plugin, extraArgs []string, osStrCmp string, t *testing.T) {
-	cmd, args, err := p.PrepareCommand(extraArgs)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if cmd != "echo" {
-		t.Fatalf("Expected echo, got %q", cmd)
-	}
-
-	if l := len(args); l != 5 {
-		t.Fatalf("expected 5 args, got %d", l)
-	}
+func TestPrepareCommand(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
 
-	expect := []string{"-n", osStrCmp, "--debug", "--foo", "bar"}
-	for i := 0; i < len(args); i++ {
-		if expect[i] != args[i] {
-			t.Errorf("Expected arg=%q, got %q", expect[i], args[i])
-		}
+	p := &Plugin{
+		Dir: "/tmp", // Unused
+		Metadata: &Metadata{
+			Name:    "test",
+			Command: "echo \"error\"",
+			PlatformCommand: []PlatformCommand{
+				{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+			},
+		},
 	}
 
-	// Test with IgnoreFlags. This should omit --debug, --foo, bar
-	p.Metadata.IgnoreFlags = true
-	cmd, args, err = p.PrepareCommand(extraArgs)
+	cmd, args, err := p.PrepareCommand([]string{})
 	if err != nil {
 		t.Fatal(err)
 	}
-	if cmd != "echo" {
-		t.Fatalf("Expected echo, got %q", cmd)
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
 	}
-	if l := len(args); l != 2 {
-		t.Fatalf("expected 2 args, got %d", l)
-	}
-	expect = []string{"-n", osStrCmp}
-	for i := 0; i < len(args); i++ {
-		if expect[i] != args[i] {
-			t.Errorf("Expected arg=%q, got %q", expect[i], args[i])
-		}
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
 	}
 }
 
-func TestPrepareCommand(t *testing.T) {
+func TestPrepareCommandExtraArgs(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+	extraArgs := []string{"--debug", "--foo", "bar"}
+
 	p := &Plugin{
 		Dir: "/tmp", // Unused
 		Metadata: &Metadata{
 			Name:    "test",
-			Command: "echo -n foo",
+			Command: "echo \"error\"",
+			PlatformCommand: []PlatformCommand{
+				{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+				{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+			},
 		},
 	}
-	argv := []string{"--debug", "--foo", "bar"}
 
-	checkCommand(p, argv, "foo", t)
+	expectedArgs := append(cmdArgs, extraArgs...)
+
+	cmd, args, err := p.PrepareCommand(extraArgs)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, expectedArgs) {
+		t.Fatalf("Expected %v, got %v", expectedArgs, args)
+	}
 }
 
-func TestPlatformPrepareCommand(t *testing.T) {
+func TestPrepareCommandExtraArgsIgnored(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+	extraArgs := []string{"--debug", "--foo", "bar"}
+
 	p := &Plugin{
 		Dir: "/tmp", // Unused
 		Metadata: &Metadata{
 			Name:    "test",
-			Command: "echo -n os-arch",
+			Command: "echo \"error\"",
 			PlatformCommand: []PlatformCommand{
-				{OperatingSystem: "linux", Architecture: "386", Command: "echo -n linux-386"},
-				{OperatingSystem: "linux", Architecture: "amd64", Command: "echo -n linux-amd64"},
-				{OperatingSystem: "linux", Architecture: "arm64", Command: "echo -n linux-arm64"},
-				{OperatingSystem: "linux", Architecture: "ppc64le", Command: "echo -n linux-ppc64le"},
-				{OperatingSystem: "linux", Architecture: "s390x", Command: "echo -n linux-s390x"},
-				{OperatingSystem: "linux", Architecture: "riscv64", Command: "echo -n linux-riscv64"},
-				{OperatingSystem: "linux", Architecture: "loong64", Command: "echo -n linux-loong64"},
-				{OperatingSystem: "windows", Architecture: "amd64", Command: "echo -n win-64"},
+				{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+				{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
 			},
+			IgnoreFlags: true,
 		},
 	}
-	var osStrCmp string
-	os := runtime.GOOS
-	arch := runtime.GOARCH
-	if os == "linux" && arch == "386" {
-		osStrCmp = "linux-386"
-	} else if os == "linux" && arch == "amd64" {
-		osStrCmp = "linux-amd64"
-	} else if os == "linux" && arch == "arm64" {
-		osStrCmp = "linux-arm64"
-	} else if os == "linux" && arch == "ppc64le" {
-		osStrCmp = "linux-ppc64le"
-	} else if os == "linux" && arch == "s390x" {
-		osStrCmp = "linux-s390x"
-	} else if os == "linux" && arch == "riscv64" {
-		osStrCmp = "linux-riscv64"
-	} else if os == "linux" && arch == "loong64" {
-		osStrCmp = "linux-loong64"
-	} else if os == "windows" && arch == "amd64" {
-		osStrCmp = "win-64"
-	} else {
-		osStrCmp = "os-arch"
-	}
-
-	argv := []string{"--debug", "--foo", "bar"}
-	checkCommand(p, argv, osStrCmp, t)
+
+	cmd, args, err := p.PrepareCommand(extraArgs)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
+	}
 }
 
-func TestPartialPlatformPrepareCommand(t *testing.T) {
-	p := &Plugin{
-		Dir: "/tmp", // Unused
-		Metadata: &Metadata{
-			Name:    "test",
-			Command: "echo -n os-arch",
-			PlatformCommand: []PlatformCommand{
-				{OperatingSystem: "linux", Architecture: "386", Command: "echo -n linux-386"},
-				{OperatingSystem: "windows", Architecture: "amd64", Command: "echo -n win-64"},
-			},
-		},
+func TestPrepareCommands(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+
+	cmds := []PlatformCommand{
+		{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+		{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+	}
+
+	cmd, args, err := PrepareCommands(cmds, true, []string{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
 	}
-	var osStrCmp string
-	os := runtime.GOOS
-	arch := runtime.GOARCH
-	if os == "linux" {
-		osStrCmp = "linux-386"
-	} else if os == "windows" && arch == "amd64" {
-		osStrCmp = "win-64"
-	} else {
-		osStrCmp = "os-arch"
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
+	}
+}
+
+func TestPrepareCommandsExtraArgs(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+	extraArgs := []string{"--debug", "--foo", "bar"}
+
+	cmds := []PlatformCommand{
+		{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
 	}
 
-	argv := []string{"--debug", "--foo", "bar"}
-	checkCommand(p, argv, osStrCmp, t)
+	expectedArgs := append(cmdArgs, extraArgs...)
+
+	cmd, args, err := PrepareCommands(cmds, true, extraArgs)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, expectedArgs) {
+		t.Fatalf("Expected %v, got %v", expectedArgs, args)
+	}
 }
 
-func TestNoPrepareCommand(t *testing.T) {
-	p := &Plugin{
-		Dir: "/tmp", // Unused
-		Metadata: &Metadata{
-			Name: "test",
-		},
+func TestPrepareCommandsNoArch(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+
+	cmds := []PlatformCommand{
+		{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
 	}
-	argv := []string{"--debug", "--foo", "bar"}
 
-	_, _, err := p.PrepareCommand(argv)
-	if err == nil {
-		t.Fatalf("Expected error to be returned")
+	cmd, args, err := PrepareCommands(cmds, true, []string{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
 	}
 }
 
-func TestNoMatchPrepareCommand(t *testing.T) {
-	p := &Plugin{
-		Dir: "/tmp", // Unused
-		Metadata: &Metadata{
-			Name: "test",
-			PlatformCommand: []PlatformCommand{
-				{OperatingSystem: "no-os", Architecture: "amd64", Command: "echo -n linux-386"},
-			},
-		},
+func TestPrepareCommandsNoOsNoArch(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+
+	cmds := []PlatformCommand{
+		{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: "", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
 	}
-	argv := []string{"--debug", "--foo", "bar"}
 
-	if _, _, err := p.PrepareCommand(argv); err == nil {
+	cmd, args, err := PrepareCommands(cmds, true, []string{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
+	}
+}
+
+func TestPrepareCommandsNoMatch(t *testing.T) {
+	cmds := []PlatformCommand{
+		{OperatingSystem: "no-os", Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+		{OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
+	}
+
+	if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
 		t.Fatalf("Expected error to be returned")
 	}
 }
 
+func TestPrepareCommandsNoCommands(t *testing.T) {
+	cmds := []PlatformCommand{}
+
+	if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
+		t.Fatalf("Expected error to be returned")
+	}
+}
+
+func TestPrepareCommandsExpand(t *testing.T) {
+	t.Setenv("TEST", "test")
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"${TEST}\""}
+	cmds := []PlatformCommand{
+		{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
+	}
+
+	expectedArgs := []string{"-c", "echo \"test\""}
+
+	cmd, args, err := PrepareCommands(cmds, true, []string{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, expectedArgs) {
+		t.Fatalf("Expected %v, got %v", expectedArgs, args)
+	}
+}
+
+func TestPrepareCommandsNoExpand(t *testing.T) {
+	t.Setenv("TEST", "test")
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"${TEST}\""}
+	cmds := []PlatformCommand{
+		{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
+	}
+
+	cmd, args, err := PrepareCommands(cmds, false, []string{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
+	}
+}
+
 func TestLoadDir(t *testing.T) {
 	dirname := "testdata/plugdir/good/hello"
 	plug, err := LoadDir(dirname)
@@ -199,10 +292,16 @@ func TestLoadDir(t *testing.T) {
 		Version:     "0.1.0",
 		Usage:       "usage",
 		Description: "description",
-		Command:     "$HELM_PLUGIN_DIR/hello.sh",
+		PlatformCommand: []PlatformCommand{
+			{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.sh"}},
+			{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.ps1"}},
+		},
 		IgnoreFlags: true,
-		Hooks: map[string]string{
-			Install: "echo installing...",
+		PlatformHooks: map[string][]PlatformCommand{
+			Install: {
+				{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
+				{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
+			},
 		},
 	}
 
@@ -249,7 +348,6 @@ func TestDownloader(t *testing.T) {
 }
 
 func TestLoadAll(t *testing.T) {
-
 	// Verify that empty dir loads:
 	if plugs, err := LoadAll("testdata"); err != nil {
 		t.Fatalf("error loading dir with no plugins: %s", err)
@@ -361,6 +459,30 @@ func TestValidatePluginData(t *testing.T) {
 		Dir: "no-such-dir",
 	}
 
+	// A mock plugin with no commands
+	mockNoCommand := mockPlugin("foo")
+	mockNoCommand.Metadata.PlatformCommand = []PlatformCommand{}
+	mockNoCommand.Metadata.PlatformHooks = map[string][]PlatformCommand{}
+
+	// A mock plugin with legacy commands
+	mockLegacyCommand := mockPlugin("foo")
+	mockLegacyCommand.Metadata.PlatformCommand = []PlatformCommand{}
+	mockLegacyCommand.Metadata.Command = "echo \"mock plugin\""
+	mockLegacyCommand.Metadata.PlatformHooks = map[string][]PlatformCommand{}
+	mockLegacyCommand.Metadata.Hooks = map[string]string{
+		Install: "echo installing...",
+	}
+
+	// A mock plugin with a command also set
+	mockWithCommand := mockPlugin("foo")
+	mockWithCommand.Metadata.Command = "echo \"mock plugin\""
+
+	// A mock plugin with a hooks also set
+	mockWithHooks := mockPlugin("foo")
+	mockWithHooks.Metadata.Hooks = map[string]string{
+		Install: "echo installing...",
+	}
+
 	for i, item := range []struct {
 		pass bool
 		plug *Plugin
@@ -372,6 +494,10 @@ func TestValidatePluginData(t *testing.T) {
 		{false, mockPlugin("foo -bar ")}, // Test trailing chars
 		{false, mockPlugin("foo\nbar")},  // Test newline
 		{false, mockMissingMeta},         // Test if the metadata section missing
+		{true, mockNoCommand},            // Test no command metadata works
+		{true, mockLegacyCommand},        // Test legacy command metadata works
+		{false, mockWithCommand},         // Test platformCommand and command both set fails
+		{false, mockWithHooks},           // Test platformHooks and hooks both set fails
 	} {
 		err := validatePluginData(item.plug, fmt.Sprintf("test-%d", i))
 		if item.pass && err != nil {
@@ -403,7 +529,16 @@ func mockPlugin(name string) *Plugin {
 			Version:     "v0.1.2",
 			Usage:       "Mock plugin",
 			Description: "Mock plugin for testing",
-			Command:     "echo mock plugin",
+			PlatformCommand: []PlatformCommand{
+				{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
+				{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"mock plugin\""}},
+			},
+			PlatformHooks: map[string][]PlatformCommand{
+				Install: {
+					{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
+					{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
+				},
+			},
 		},
 		Dir: "no-such-dir",
 	}
diff --git a/pkg/plugin/testdata/plugdir/good/hello/hello.ps1 b/pkg/plugin/testdata/plugdir/good/hello/hello.ps1
new file mode 100644
index 000000000..bee61f27d
--- /dev/null
+++ b/pkg/plugin/testdata/plugdir/good/hello/hello.ps1
@@ -0,0 +1,3 @@
+#!/usr/bin/env pwsh
+
+Write-Host "Hello, world!"
diff --git a/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml b/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml
index b857b55ee..71dc88259 100644
--- a/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml
+++ b/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml
@@ -3,7 +3,23 @@ version: "0.1.0"
 usage: "usage"
 description: |-
   description
-command: "$HELM_PLUGIN_DIR/hello.sh"
+platformCommand:
+  - os: linux
+    arch:
+    command: "sh"
+    args: ["-c", "${HELM_PLUGIN_DIR}/hello.sh"]
+  - os: windows
+    arch:
+    command: "pwsh"
+    args: ["-c", "${HELM_PLUGIN_DIR}/hello.ps1"]
 ignoreFlags: true
-hooks:
-  install: "echo installing..."
+platformHooks:
+  install:
+    - os: linux
+      arch: ""
+      command: "sh"
+      args: ["-c", 'echo "installing..."']
+    - os: windows
+      arch: ""
+      command: "pwsh"
+      args: ["-c", 'echo "installing..."']