Repository: restic/restic (go)

Issue:
`key` help does not document subcommands
Output of `restic version`
--------------------------
restic 0.16.3 compiled with go1.21.6 on linux/amd64


Problem description
----------------------------------------

`restic help key` shows the list/add/remove/passwd subcommands but does not show help for what they do. Specifically, the difference between "add" and "passwd" is not explained.


Did restic help you today? Did it make you happy in any way?
------------------------------------------------------------

Today and every day since I first found it.

Pull request:
Move key add, list, remove and passwd as separate sub-commands and improve key sub-command documentation
<!--
Thank you very much for contributing code or documentation to restic! Please
fill out the following questions to make it easier for us to review your
changes.
-->

What does this PR change? What problem does it solve?
-----------------------------------------------------
Restic previously used command line args to figure out the command to be executed for the `key` command. The documentation of the key command was limited. 

@MichaelEischer requested the key command to be split into their own sub-commands like how the `repair` command is structured.

This PR has split the `key` command into their respective sub-commands using spf13/cobra's sub-command feature. 
<!--
Describe the changes and their purpose here, as detailed as needed.
-->

Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------
Closes #4676 

> We should split the key command into multiple subcommands, similiar to the repair command.
> 
> "add" create a new key. "passwd" replaces the current key with a new one using the new password.

<!--
Link issues and relevant forum posts here.

If this PR resolves an issue on GitHub, use "Closes #1234" so that the issue
is closed automatically when this PR is merged.
-->

Checklist
---------

<!--
You do not need to check all the boxes below all at once. Feel free to take
your time and add more commits. If you're done and ready for review, please
check the last box. Enable a checkbox by replacing [ ] with [x].
-->

- [x] I have read the [contribution guidelines](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#providing-patches).
- [x] I have [enabled maintainer edits](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork).
- [x] I have added tests for all code changes.
- - reused existing integration tests, fixed the test and checked if everything is working 
- [x] I have added documentation for relevant changes (in the manual).
- [x] There's a new file in `changelog/unreleased/` that describes the changes for our users (see [template](https://github.com/restic/restic/blob/master/changelog/TEMPLATE)).
- [x] I have run `gofmt` on the code in all commits.
- [x] All commit messages are formatted in the same style as [the other commits in the repo](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#git-commits).
- [x] I'm done! This pull request is ready for review.


Patch:
diff --git a/changelog/unreleased/issue-4676 b/changelog/unreleased/issue-4676
new file mode 100644
index 000000000..e95118e72
--- /dev/null
+++ b/changelog/unreleased/issue-4676
@@ -0,0 +1,8 @@
+Enhancement: Move key add, list, remove and passwd as separate sub-commands
+
+Restic now provides usage documentation for the `key` command. Each sub-command;
+`add`, `list`, `remove` and `passwd` now have their own sub-command documentation
+which can be invoked using `restic key <add|list|remove|passwd> --help`.
+
+https://github.com/restic/restic/issues/4676
+https://github.com/restic/restic/pull/4685
diff --git a/cmd/restic/cmd_key.go b/cmd/restic/cmd_key.go
index 14609e6e9..c687eca53 100644
--- a/cmd/restic/cmd_key.go
+++ b/cmd/restic/cmd_key.go
@@ -1,264 +1,18 @@
 package main
 
 import (
-	"context"
-	"encoding/json"
-	"os"
-	"strings"
-	"sync"
-
-	"github.com/restic/restic/internal/errors"
-	"github.com/restic/restic/internal/repository"
-	"github.com/restic/restic/internal/restic"
-	"github.com/restic/restic/internal/ui/table"
-
 	"github.com/spf13/cobra"
 )
 
 var cmdKey = &cobra.Command{
-	Use:   "key [flags] [list|add|remove|passwd] [ID]",
+	Use:   "key",
 	Short: "Manage keys (passwords)",
 	Long: `
-The "key" command manages keys (passwords) for accessing the repository.
-
-EXIT STATUS
-===========
-
-Exit status is 0 if the command was successful, and non-zero if there was any error.
-`,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runKey(cmd.Context(), globalOptions, keyOpts, args)
-	},
+The "key" command allows you to set multiple access keys or passwords
+per repository.
+	`,
 }
 
-type KeyOptions struct {
-	NewPasswordFile string
-	Username        string
-	Hostname        string
-}
-
-var keyOpts KeyOptions
-
 func init() {
 	cmdRoot.AddCommand(cmdKey)
-
-	flags := cmdKey.Flags()
-	flags.StringVarP(&keyOpts.NewPasswordFile, "new-password-file", "", "", "`file` from which to read the new password")
-	flags.StringVarP(&keyOpts.Username, "user", "", "", "the username for new keys")
-	flags.StringVarP(&keyOpts.Hostname, "host", "", "", "the hostname for new keys")
-}
-
-func listKeys(ctx context.Context, s *repository.Repository, gopts GlobalOptions) error {
-	type keyInfo struct {
-		Current  bool   `json:"current"`
-		ID       string `json:"id"`
-		UserName string `json:"userName"`
-		HostName string `json:"hostName"`
-		Created  string `json:"created"`
-	}
-
-	var m sync.Mutex
-	var keys []keyInfo
-
-	err := restic.ParallelList(ctx, s, restic.KeyFile, s.Connections(), func(ctx context.Context, id restic.ID, size int64) error {
-		k, err := repository.LoadKey(ctx, s, id)
-		if err != nil {
-			Warnf("LoadKey() failed: %v\n", err)
-			return nil
-		}
-
-		key := keyInfo{
-			Current:  id == s.KeyID(),
-			ID:       id.Str(),
-			UserName: k.Username,
-			HostName: k.Hostname,
-			Created:  k.Created.Local().Format(TimeFormat),
-		}
-
-		m.Lock()
-		defer m.Unlock()
-		keys = append(keys, key)
-		return nil
-	})
-
-	if err != nil {
-		return err
-	}
-
-	if gopts.JSON {
-		return json.NewEncoder(globalOptions.stdout).Encode(keys)
-	}
-
-	tab := table.New()
-	tab.AddColumn(" ID", "{{if .Current}}*{{else}} {{end}}{{ .ID }}")
-	tab.AddColumn("User", "{{ .UserName }}")
-	tab.AddColumn("Host", "{{ .HostName }}")
-	tab.AddColumn("Created", "{{ .Created }}")
-
-	for _, key := range keys {
-		tab.AddRow(key)
-	}
-
-	return tab.Write(globalOptions.stdout)
-}
-
-// testKeyNewPassword is used to set a new password during integration testing.
-var testKeyNewPassword string
-
-func getNewPassword(gopts GlobalOptions, newPasswordFile string) (string, error) {
-	if testKeyNewPassword != "" {
-		return testKeyNewPassword, nil
-	}
-
-	if newPasswordFile != "" {
-		return loadPasswordFromFile(newPasswordFile)
-	}
-
-	// Since we already have an open repository, temporary remove the password
-	// to prompt the user for the passwd.
-	newopts := gopts
-	newopts.password = ""
-
-	return ReadPasswordTwice(newopts,
-		"enter new password: ",
-		"enter password again: ")
-}
-
-func addKey(ctx context.Context, repo *repository.Repository, gopts GlobalOptions, opts KeyOptions) error {
-	pw, err := getNewPassword(gopts, opts.NewPasswordFile)
-	if err != nil {
-		return err
-	}
-
-	id, err := repository.AddKey(ctx, repo, pw, opts.Username, opts.Hostname, repo.Key())
-	if err != nil {
-		return errors.Fatalf("creating new key failed: %v\n", err)
-	}
-
-	err = switchToNewKeyAndRemoveIfBroken(ctx, repo, id, pw)
-	if err != nil {
-		return err
-	}
-
-	Verbosef("saved new key with ID %s\n", id.ID())
-
-	return nil
-}
-
-func deleteKey(ctx context.Context, repo *repository.Repository, id restic.ID) error {
-	if id == repo.KeyID() {
-		return errors.Fatal("refusing to remove key currently used to access repository")
-	}
-
-	err := repository.RemoveKey(ctx, repo, id)
-	if err != nil {
-		return err
-	}
-
-	Verbosef("removed key %v\n", id)
-	return nil
-}
-
-func changePassword(ctx context.Context, repo *repository.Repository, gopts GlobalOptions, newPasswordFile string) error {
-	pw, err := getNewPassword(gopts, newPasswordFile)
-	if err != nil {
-		return err
-	}
-
-	id, err := repository.AddKey(ctx, repo, pw, "", "", repo.Key())
-	if err != nil {
-		return errors.Fatalf("creating new key failed: %v\n", err)
-	}
-	oldID := repo.KeyID()
-
-	err = switchToNewKeyAndRemoveIfBroken(ctx, repo, id, pw)
-	if err != nil {
-		return err
-	}
-
-	err = repository.RemoveKey(ctx, repo, oldID)
-	if err != nil {
-		return err
-	}
-
-	Verbosef("saved new key as %s\n", id)
-
-	return nil
-}
-
-func switchToNewKeyAndRemoveIfBroken(ctx context.Context, repo *repository.Repository, key *repository.Key, pw string) error {
-	// Verify new key to make sure it really works. A broken key can render the
-	// whole repository inaccessible
-	err := repo.SearchKey(ctx, pw, 0, key.ID().String())
-	if err != nil {
-		// the key is invalid, try to remove it
-		_ = repository.RemoveKey(ctx, repo, key.ID())
-		return errors.Fatalf("failed to access repository with new key: %v", err)
-	}
-	return nil
-}
-
-func runKey(ctx context.Context, gopts GlobalOptions, opts KeyOptions, args []string) error {
-	if len(args) < 1 || (args[0] == "remove" && len(args) != 2) || (args[0] != "remove" && len(args) != 1) {
-		return errors.Fatal("wrong number of arguments")
-	}
-
-	repo, err := OpenRepository(ctx, gopts)
-	if err != nil {
-		return err
-	}
-
-	switch args[0] {
-	case "list":
-		if !gopts.NoLock {
-			var lock *restic.Lock
-			lock, ctx, err = lockRepo(ctx, repo, gopts.RetryLock, gopts.JSON)
-			defer unlockRepo(lock)
-			if err != nil {
-				return err
-			}
-		}
-
-		return listKeys(ctx, repo, gopts)
-	case "add":
-		lock, ctx, err := lockRepo(ctx, repo, gopts.RetryLock, gopts.JSON)
-		defer unlockRepo(lock)
-		if err != nil {
-			return err
-		}
-
-		return addKey(ctx, repo, gopts, opts)
-	case "remove":
-		lock, ctx, err := lockRepoExclusive(ctx, repo, gopts.RetryLock, gopts.JSON)
-		defer unlockRepo(lock)
-		if err != nil {
-			return err
-		}
-
-		id, err := restic.Find(ctx, repo, restic.KeyFile, args[1])
-		if err != nil {
-			return err
-		}
-
-		return deleteKey(ctx, repo, id)
-	case "passwd":
-		lock, ctx, err := lockRepoExclusive(ctx, repo, gopts.RetryLock, gopts.JSON)
-		defer unlockRepo(lock)
-		if err != nil {
-			return err
-		}
-
-		return changePassword(ctx, repo, gopts, opts.NewPasswordFile)
-	default:
-		return errors.Fatal("invalid operation")
-	}
-}
-
-func loadPasswordFromFile(pwdFile string) (string, error) {
-	s, err := os.ReadFile(pwdFile)
-	if os.IsNotExist(err) {
-		return "", errors.Fatalf("%s does not exist", pwdFile)
-	}
-	return strings.TrimSpace(string(s)), errors.Wrap(err, "Readfile")
 }
diff --git a/cmd/restic/cmd_key_add.go b/cmd/restic/cmd_key_add.go
new file mode 100644
index 000000000..43a38f4eb
--- /dev/null
+++ b/cmd/restic/cmd_key_add.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/restic/restic/internal/errors"
+	"github.com/restic/restic/internal/repository"
+	"github.com/spf13/cobra"
+)
+
+var cmdKeyAdd = &cobra.Command{
+	Use:   "add",
+	Short: "Add a new key (password) to the repository; returns the new key ID",
+	Long: `
+The "add" sub-command creates a new key and validates the key. Returns the new key ID.
+
+EXIT STATUS
+===========
+
+Exit status is 0 if the command is successful, and non-zero if there was any error.
+	`,
+	DisableAutoGenTag: true,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return runKeyAdd(cmd.Context(), globalOptions, keyAddOpts, args)
+	},
+}
+
+type KeyAddOptions struct {
+	NewPasswordFile string
+	Username        string
+	Hostname        string
+}
+
+var keyAddOpts KeyAddOptions
+
+func init() {
+	cmdKey.AddCommand(cmdKeyAdd)
+
+	flags := cmdKeyAdd.Flags()
+	flags.StringVarP(&keyAddOpts.NewPasswordFile, "new-password-file", "", "", "`file` from which to read the new password")
+	flags.StringVarP(&keyAddOpts.Username, "user", "", "", "the username for new key")
+	flags.StringVarP(&keyAddOpts.Hostname, "host", "", "", "the hostname for new key")
+}
+
+func runKeyAdd(ctx context.Context, gopts GlobalOptions, opts KeyAddOptions, args []string) error {
+	if len(args) > 0 {
+		return fmt.Errorf("the key add command expects no arguments, only options - please see `restic help key add` for usage and flags")
+	}
+
+	repo, err := OpenRepository(ctx, gopts)
+	if err != nil {
+		return err
+	}
+
+	lock, ctx, err := lockRepo(ctx, repo, gopts.RetryLock, gopts.JSON)
+	defer unlockRepo(lock)
+	if err != nil {
+		return err
+	}
+
+	return addKey(ctx, repo, gopts, opts)
+}
+
+func addKey(ctx context.Context, repo *repository.Repository, gopts GlobalOptions, opts KeyAddOptions) error {
+	pw, err := getNewPassword(gopts, opts.NewPasswordFile)
+	if err != nil {
+		return err
+	}
+
+	id, err := repository.AddKey(ctx, repo, pw, opts.Username, opts.Hostname, repo.Key())
+	if err != nil {
+		return errors.Fatalf("creating new key failed: %v\n", err)
+	}
+
+	err = switchToNewKeyAndRemoveIfBroken(ctx, repo, id, pw)
+	if err != nil {
+		return err
+	}
+
+	Verbosef("saved new key with ID %s\n", id.ID())
+
+	return nil
+}
+
+// testKeyNewPassword is used to set a new password during integration testing.
+var testKeyNewPassword string
+
+func getNewPassword(gopts GlobalOptions, newPasswordFile string) (string, error) {
+	if testKeyNewPassword != "" {
+		return testKeyNewPassword, nil
+	}
+
+	if newPasswordFile != "" {
+		return loadPasswordFromFile(newPasswordFile)
+	}
+
+	// Since we already have an open repository, temporary remove the password
+	// to prompt the user for the passwd.
+	newopts := gopts
+	newopts.password = ""
+
+	return ReadPasswordTwice(newopts,
+		"enter new password: ",
+		"enter password again: ")
+}
+
+func loadPasswordFromFile(pwdFile string) (string, error) {
+	s, err := os.ReadFile(pwdFile)
+	if os.IsNotExist(err) {
+		return "", errors.Fatalf("%s does not exist", pwdFile)
+	}
+	return strings.TrimSpace(string(s)), errors.Wrap(err, "Readfile")
+}
+
+func switchToNewKeyAndRemoveIfBroken(ctx context.Context, repo *repository.Repository, key *repository.Key, pw string) error {
+	// Verify new key to make sure it really works. A broken key can render the
+	// whole repository inaccessible
+	err := repo.SearchKey(ctx, pw, 0, key.ID().String())
+	if err != nil {
+		// the key is invalid, try to remove it
+		_ = repository.RemoveKey(ctx, repo, key.ID())
+		return errors.Fatalf("failed to access repository with new key: %v", err)
+	}
+	return nil
+}
diff --git a/cmd/restic/cmd_key_list.go b/cmd/restic/cmd_key_list.go
new file mode 100644
index 000000000..517b7c84b
--- /dev/null
+++ b/cmd/restic/cmd_key_list.go
@@ -0,0 +1,112 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"sync"
+
+	"github.com/restic/restic/internal/repository"
+	"github.com/restic/restic/internal/restic"
+	"github.com/restic/restic/internal/ui/table"
+	"github.com/spf13/cobra"
+)
+
+var cmdKeyList = &cobra.Command{
+	Use:   "list",
+	Short: "List keys (passwords)",
+	Long: `
+The "list" sub-command lists all the keys (passwords) associated with the repository.
+Returns the key ID, username, hostname, created time and if it's the current key being
+used to access the repository.
+
+EXIT STATUS
+===========
+
+Exit status is 0 if the command is successful, and non-zero if there was any error.
+	`,
+	DisableAutoGenTag: true,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return runKeyList(cmd.Context(), globalOptions, args)
+	},
+}
+
+func init() {
+	cmdKey.AddCommand(cmdKeyList)
+}
+
+func runKeyList(ctx context.Context, gopts GlobalOptions, args []string) error {
+	if len(args) > 0 {
+		return fmt.Errorf("the key list command expects no arguments, only options - please see `restic help key list` for usage and flags")
+	}
+
+	repo, err := OpenRepository(ctx, gopts)
+	if err != nil {
+		return err
+	}
+
+	if !gopts.NoLock {
+		var lock *restic.Lock
+		lock, ctx, err = lockRepo(ctx, repo, gopts.RetryLock, gopts.JSON)
+		defer unlockRepo(lock)
+		if err != nil {
+			return err
+		}
+	}
+
+	return listKeys(ctx, repo, gopts)
+}
+
+func listKeys(ctx context.Context, s *repository.Repository, gopts GlobalOptions) error {
+	type keyInfo struct {
+		Current  bool   `json:"current"`
+		ID       string `json:"id"`
+		UserName string `json:"userName"`
+		HostName string `json:"hostName"`
+		Created  string `json:"created"`
+	}
+
+	var m sync.Mutex
+	var keys []keyInfo
+
+	err := restic.ParallelList(ctx, s, restic.KeyFile, s.Connections(), func(ctx context.Context, id restic.ID, size int64) error {
+		k, err := repository.LoadKey(ctx, s, id)
+		if err != nil {
+			Warnf("LoadKey() failed: %v\n", err)
+			return nil
+		}
+
+		key := keyInfo{
+			Current:  id == s.KeyID(),
+			ID:       id.Str(),
+			UserName: k.Username,
+			HostName: k.Hostname,
+			Created:  k.Created.Local().Format(TimeFormat),
+		}
+
+		m.Lock()
+		defer m.Unlock()
+		keys = append(keys, key)
+		return nil
+	})
+
+	if err != nil {
+		return err
+	}
+
+	if gopts.JSON {
+		return json.NewEncoder(globalOptions.stdout).Encode(keys)
+	}
+
+	tab := table.New()
+	tab.AddColumn(" ID", "{{if .Current}}*{{else}} {{end}}{{ .ID }}")
+	tab.AddColumn("User", "{{ .UserName }}")
+	tab.AddColumn("Host", "{{ .HostName }}")
+	tab.AddColumn("Created", "{{ .Created }}")
+
+	for _, key := range keys {
+		tab.AddRow(key)
+	}
+
+	return tab.Write(globalOptions.stdout)
+}
diff --git a/cmd/restic/cmd_key_passwd.go b/cmd/restic/cmd_key_passwd.go
new file mode 100644
index 000000000..cb916274c
--- /dev/null
+++ b/cmd/restic/cmd_key_passwd.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/restic/restic/internal/errors"
+	"github.com/restic/restic/internal/repository"
+	"github.com/spf13/cobra"
+)
+
+var cmdKeyPasswd = &cobra.Command{
+	Use:   "passwd",
+	Short: "Change key (password); creates a new key ID and removes the old key ID, returns new key ID",
+	Long: `
+The "passwd" sub-command creates a new key, validates the key and remove the old key ID.
+Returns the new key ID. 
+
+EXIT STATUS
+===========
+
+Exit status is 0 if the command is successful, and non-zero if there was any error.
+	`,
+	DisableAutoGenTag: true,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return runKeyPasswd(cmd.Context(), globalOptions, keyPasswdOpts, args)
+	},
+}
+
+type KeyPasswdOptions struct {
+	KeyAddOptions
+}
+
+var keyPasswdOpts KeyPasswdOptions
+
+func init() {
+	cmdKey.AddCommand(cmdKeyPasswd)
+
+	flags := cmdKeyPasswd.Flags()
+	flags.StringVarP(&keyPasswdOpts.NewPasswordFile, "new-password-file", "", "", "`file` from which to read the new password")
+	flags.StringVarP(&keyPasswdOpts.Username, "user", "", "", "the username for new key")
+	flags.StringVarP(&keyPasswdOpts.Hostname, "host", "", "", "the hostname for new key")
+}
+
+func runKeyPasswd(ctx context.Context, gopts GlobalOptions, opts KeyPasswdOptions, args []string) error {
+	if len(args) > 0 {
+		return fmt.Errorf("the key passwd command expects no arguments, only options - please see `restic help key passwd` for usage and flags")
+	}
+
+	repo, err := OpenRepository(ctx, gopts)
+	if err != nil {
+		return err
+	}
+
+	lock, ctx, err := lockRepoExclusive(ctx, repo, gopts.RetryLock, gopts.JSON)
+	defer unlockRepo(lock)
+	if err != nil {
+		return err
+	}
+
+	return changePassword(ctx, repo, gopts, opts)
+}
+
+func changePassword(ctx context.Context, repo *repository.Repository, gopts GlobalOptions, opts KeyPasswdOptions) error {
+	pw, err := getNewPassword(gopts, opts.NewPasswordFile)
+	if err != nil {
+		return err
+	}
+
+	id, err := repository.AddKey(ctx, repo, pw, "", "", repo.Key())
+	if err != nil {
+		return errors.Fatalf("creating new key failed: %v\n", err)
+	}
+	oldID := repo.KeyID()
+
+	err = switchToNewKeyAndRemoveIfBroken(ctx, repo, id, pw)
+	if err != nil {
+		return err
+	}
+
+	err = repository.RemoveKey(ctx, repo, oldID)
+	if err != nil {
+		return err
+	}
+
+	Verbosef("saved new key as %s\n", id)
+
+	return nil
+}
diff --git a/cmd/restic/cmd_key_remove.go b/cmd/restic/cmd_key_remove.go
new file mode 100644
index 000000000..c8e303ffc
--- /dev/null
+++ b/cmd/restic/cmd_key_remove.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/restic/restic/internal/errors"
+	"github.com/restic/restic/internal/repository"
+	"github.com/restic/restic/internal/restic"
+	"github.com/spf13/cobra"
+)
+
+var cmdKeyRemove = &cobra.Command{
+	Use:   "remove [ID]",
+	Short: "Remove key ID (password) from the repository.",
+	Long: `
+The "remove" sub-command removes the selected key ID. The "remove" command does not allow
+removing the current key being used to access the repository. 
+
+EXIT STATUS
+===========
+
+Exit status is 0 if the command is successful, and non-zero if there was any error.
+	`,
+	DisableAutoGenTag: true,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return runKeyRemove(cmd.Context(), globalOptions, args)
+	},
+}
+
+func init() {
+	cmdKey.AddCommand(cmdKeyRemove)
+}
+
+func runKeyRemove(ctx context.Context, gopts GlobalOptions, args []string) error {
+	if len(args) != 1 {
+		return fmt.Errorf("key remove expects one argument as the key id")
+	}
+
+	repo, err := OpenRepository(ctx, gopts)
+	if err != nil {
+		return err
+	}
+
+	lock, ctx, err := lockRepoExclusive(ctx, repo, gopts.RetryLock, gopts.JSON)
+	defer unlockRepo(lock)
+	if err != nil {
+		return err
+	}
+
+	idPrefix := args[0]
+
+	return deleteKey(ctx, repo, idPrefix)
+}
+
+func deleteKey(ctx context.Context, repo *repository.Repository, idPrefix string) error {
+	id, err := restic.Find(ctx, repo, restic.KeyFile, idPrefix)
+	if err != nil {
+		return err
+	}
+
+	if id == repo.KeyID() {
+		return errors.Fatal("refusing to remove key currently used to access repository")
+	}
+
+	err = repository.RemoveKey(ctx, repo, id)
+	if err != nil {
+		return err
+	}
+
+	Verbosef("removed key %v\n", id)
+	return nil
+}


Test patch:
diff --git a/cmd/restic/cmd_key_integration_test.go b/cmd/restic/cmd_key_integration_test.go
index 34474c3af..16cc1bdad 100644
--- a/cmd/restic/cmd_key_integration_test.go
+++ b/cmd/restic/cmd_key_integration_test.go
@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"context"
 	"regexp"
+	"strings"
 	"testing"
 
 	"github.com/restic/restic/internal/backend"
@@ -13,7 +14,7 @@ import (
 
 func testRunKeyListOtherIDs(t testing.TB, gopts GlobalOptions) []string {
 	buf, err := withCaptureStdout(func() error {
-		return runKey(context.TODO(), gopts, KeyOptions{}, []string{"list"})
+		return runKeyList(context.TODO(), gopts, []string{})
 	})
 	rtest.OK(t, err)
 
@@ -36,7 +37,7 @@ func testRunKeyAddNewKey(t testing.TB, newPassword string, gopts GlobalOptions)
 		testKeyNewPassword = ""
 	}()
 
-	rtest.OK(t, runKey(context.TODO(), gopts, KeyOptions{}, []string{"add"}))
+	rtest.OK(t, runKeyAdd(context.TODO(), gopts, KeyAddOptions{}, []string{}))
 }
 
 func testRunKeyAddNewKeyUserHost(t testing.TB, gopts GlobalOptions) {
@@ -46,10 +47,10 @@ func testRunKeyAddNewKeyUserHost(t testing.TB, gopts GlobalOptions) {
 	}()
 
 	t.Log("adding key for john@example.com")
-	rtest.OK(t, runKey(context.TODO(), gopts, KeyOptions{
+	rtest.OK(t, runKeyAdd(context.TODO(), gopts, KeyAddOptions{
 		Username: "john",
 		Hostname: "example.com",
-	}, []string{"add"}))
+	}, []string{}))
 
 	repo, err := OpenRepository(context.TODO(), gopts)
 	rtest.OK(t, err)
@@ -66,13 +67,13 @@ func testRunKeyPasswd(t testing.TB, newPassword string, gopts GlobalOptions) {
 		testKeyNewPassword = ""
 	}()
 
-	rtest.OK(t, runKey(context.TODO(), gopts, KeyOptions{}, []string{"passwd"}))
+	rtest.OK(t, runKeyPasswd(context.TODO(), gopts, KeyPasswdOptions{}, []string{}))
 }
 
 func testRunKeyRemove(t testing.TB, gopts GlobalOptions, IDs []string) {
 	t.Logf("remove %d keys: %q\n", len(IDs), IDs)
 	for _, id := range IDs {
-		rtest.OK(t, runKey(context.TODO(), gopts, KeyOptions{}, []string{"remove", id}))
+		rtest.OK(t, runKeyRemove(context.TODO(), gopts, []string{id}))
 	}
 }
 
@@ -102,7 +103,7 @@ func TestKeyAddRemove(t *testing.T) {
 
 	env.gopts.password = passwordList[len(passwordList)-1]
 	t.Logf("testing access with last password %q\n", env.gopts.password)
-	rtest.OK(t, runKey(context.TODO(), env.gopts, KeyOptions{}, []string{"list"}))
+	rtest.OK(t, runKeyList(context.TODO(), env.gopts, []string{}))
 	testRunCheck(t, env.gopts)
 
 	testRunKeyAddNewKeyUserHost(t, env.gopts)
@@ -130,15 +131,45 @@ func TestKeyProblems(t *testing.T) {
 		testKeyNewPassword = ""
 	}()
 
-	err := runKey(context.TODO(), env.gopts, KeyOptions{}, []string{"passwd"})
+	err := runKeyPasswd(context.TODO(), env.gopts, KeyPasswdOptions{}, []string{})
 	t.Log(err)
 	rtest.Assert(t, err != nil, "expected passwd change to fail")
 
-	err = runKey(context.TODO(), env.gopts, KeyOptions{}, []string{"add"})
+	err = runKeyAdd(context.TODO(), env.gopts, KeyAddOptions{}, []string{})
 	t.Log(err)
 	rtest.Assert(t, err != nil, "expected key adding to fail")
 
 	t.Logf("testing access with initial password %q\n", env.gopts.password)
-	rtest.OK(t, runKey(context.TODO(), env.gopts, KeyOptions{}, []string{"list"}))
+	rtest.OK(t, runKeyList(context.TODO(), env.gopts, []string{}))
 	testRunCheck(t, env.gopts)
 }
+
+func TestKeyCommandInvalidArguments(t *testing.T) {
+	env, cleanup := withTestEnvironment(t)
+	defer cleanup()
+
+	testRunInit(t, env.gopts)
+	env.gopts.backendTestHook = func(r backend.Backend) (backend.Backend, error) {
+		return &emptySaveBackend{r}, nil
+	}
+
+	err := runKeyAdd(context.TODO(), env.gopts, KeyAddOptions{}, []string{"johndoe"})
+	t.Log(err)
+	rtest.Assert(t, err != nil && strings.Contains(err.Error(), "no arguments"), "unexpected error for key add: %v", err)
+
+	err = runKeyPasswd(context.TODO(), env.gopts, KeyPasswdOptions{}, []string{"johndoe"})
+	t.Log(err)
+	rtest.Assert(t, err != nil && strings.Contains(err.Error(), "no arguments"), "unexpected error for key passwd: %v", err)
+
+	err = runKeyList(context.TODO(), env.gopts, []string{"johndoe"})
+	t.Log(err)
+	rtest.Assert(t, err != nil && strings.Contains(err.Error(), "no arguments"), "unexpected error for key list: %v", err)
+
+	err = runKeyRemove(context.TODO(), env.gopts, []string{})
+	t.Log(err)
+	rtest.Assert(t, err != nil && strings.Contains(err.Error(), "one argument"), "unexpected error for key remove: %v", err)
+
+	err = runKeyRemove(context.TODO(), env.gopts, []string{"john", "doe"})
+	t.Log(err)
+	rtest.Assert(t, err != nil && strings.Contains(err.Error(), "one argument"), "unexpected error for key remove: %v", err)
+}