diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f4d1b70..c8ececc2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## Unreleased
+
+* FEATURES
+ * Add `tea deploy-keys` command for listing, creating, and deleting repository deploy keys (`tea deploy-keys list`, `tea deploy-keys create
--key-file `, `tea deploy-keys delete `)
+
## [v0.13.0](https://gitea.com/gitea/tea/releases/tag/v0.13.0) - 2026-04-05
* FEATURES
diff --git a/README.md b/README.md
index 2293b841..82c0c0bb 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,9 @@ EXAMPLES
tea webhooks list # list repository webhooks
tea webhooks list --org myorg # list organization webhooks
tea webhooks create https://example.com/hook --events push,pull_request
+ tea deploy-keys list # list repository deploy keys
+ tea deploy-keys create CI --key-file ~/.ssh/id_ed25519.pub --read-write
+ tea deploy-keys delete 7 -y # remove a deploy key without prompting
# send gitea desktop notifications every 5 minutes (bash + libnotify)
while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done
diff --git a/cmd/cmd.go b/cmd/cmd.go
index a2306d9c..686a37e8 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -42,6 +42,7 @@ func App() *cli.Command {
&CmdWiki,
&CmdWebhooks,
&CmdComments,
+ &CmdDeployKeys,
&CmdOpen,
&CmdNotifications,
diff --git a/cmd/deploy_keys.go b/cmd/deploy_keys.go
new file mode 100644
index 00000000..2ffc08b2
--- /dev/null
+++ b/cmd/deploy_keys.go
@@ -0,0 +1,64 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+ stdctx "context"
+
+ "gitea.dev/tea/cmd/deploykeys"
+ "gitea.dev/tea/cmd/flags"
+ "gitea.dev/tea/modules/context"
+ "gitea.dev/tea/modules/print"
+ "gitea.dev/tea/modules/utils"
+
+ "github.com/urfave/cli/v3"
+)
+
+// CmdDeployKeys represents the deploy-keys command for managing repository
+// deploy keys.
+var CmdDeployKeys = cli.Command{
+ Name: "deploy-keys",
+ Aliases: []string{"dk", "deploy-key"},
+ Category: catEntities,
+ Usage: "Manage repository deploy keys",
+ Description: "List, create, and delete deploy keys for a repository. To replace a key, run 'delete' then 'create'.",
+ ArgsUsage: "[]",
+ Action: runDeployKeysDefault,
+ Commands: []*cli.Command{
+ &deploykeys.CmdDeployKeysList,
+ &deploykeys.CmdDeployKeysCreate,
+ &deploykeys.CmdDeployKeysDelete,
+ },
+ Flags: flags.AllDefaultFlags,
+}
+
+func runDeployKeysDefault(ctx stdctx.Context, cmd *cli.Command) error {
+ if cmd.Args().Len() == 1 {
+ return runDeployKeyDetail(ctx, cmd, cmd.Args().First())
+ }
+ return deploykeys.RunDeployKeysList(ctx, cmd)
+}
+
+func runDeployKeyDetail(ctx stdctx.Context, cmd *cli.Command, arg string) error {
+ c, err := context.InitCommand(cmd)
+ if err != nil {
+ return err
+ }
+ if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
+ return err
+ }
+ client := c.Login.Client()
+
+ keyID, err := utils.ArgToIndex(arg)
+ if err != nil {
+ return err
+ }
+
+ key, _, err := client.GetDeployKey(ctx, c.Owner, c.Repo, keyID)
+ if err != nil {
+ return err
+ }
+ print.DeployKeyDetails(key)
+ return nil
+}
diff --git a/cmd/deploykeys/create.go b/cmd/deploykeys/create.go
new file mode 100644
index 00000000..4afc08ac
--- /dev/null
+++ b/cmd/deploykeys/create.go
@@ -0,0 +1,102 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykeys
+
+import (
+ stdctx "context"
+ "errors"
+ "fmt"
+ "os"
+
+ gitea "gitea.dev/sdk"
+
+ "gitea.dev/tea/cmd/flags"
+ "gitea.dev/tea/modules/context"
+ "github.com/urfave/cli/v3"
+)
+
+// CmdDeployKeysCreate represents a sub command of deploy-keys to create a deploy key
+var CmdDeployKeysCreate = cli.Command{
+ Name: "create",
+ Aliases: []string{"add", "c"},
+ Usage: "Create a deploy key",
+ Description: "Create a deploy key in a repository. To replace an existing key, run 'delete' followed by 'create'.",
+ ArgsUsage: "",
+ Action: runDeployKeysCreate,
+ Flags: append([]cli.Flag{
+ &cli.StringFlag{
+ Name: "key",
+ Usage: "inline armored SSH public key",
+ },
+ &cli.StringFlag{
+ Name: "key-file",
+ Usage: "path to a file containing the armored SSH public key (e.g. ~/.ssh/id_ed25519.pub)",
+ },
+ &cli.BoolFlag{
+ Name: "read-only",
+ Usage: "restrict the key to read-only access (default)",
+ Value: true,
+ },
+ &cli.BoolFlag{
+ Name: "read-write",
+ Usage: "grant the key read/write access",
+ },
+ }, flags.AllDefaultFlags...),
+}
+
+func runDeployKeysCreate(ctx stdctx.Context, cmd *cli.Command) error {
+ if cmd.Args().Len() == 0 {
+ return errors.New("key title is required")
+ }
+
+ c, err := context.InitCommand(cmd)
+ if err != nil {
+ return err
+ }
+ if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
+ return err
+ }
+ client := c.Login.Client()
+
+ keyMaterial, err := resolveKeyMaterial(cmd.String("key"), cmd.String("key-file"))
+ if err != nil {
+ return err
+ }
+
+ readOnly := cmd.Bool("read-only")
+ if cmd.IsSet("read-write") {
+ readOnly = !cmd.Bool("read-write")
+ }
+
+ key, _, err := client.CreateDeployKey(ctx, c.Owner, c.Repo, gitea.CreateKeyOption{
+ Title: cmd.Args().First(),
+ Key: keyMaterial,
+ ReadOnly: readOnly,
+ })
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("Deploy key %d created (title: %s)\n", key.ID, key.Title)
+ return nil
+}
+
+// resolveKeyMaterial returns the SSH public key from --key (inline) or --key-file.
+// The two flags are mutually exclusive; exactly one must be supplied.
+func resolveKeyMaterial(inline, file string) (string, error) {
+ switch {
+ case inline != "" && file != "":
+ return "", errors.New("--key and --key-file are mutually exclusive")
+ case inline == "" && file == "":
+ return "", errors.New("either --key or --key-file is required")
+ case inline != "":
+ return inline, nil
+ default:
+ data, err := os.ReadFile(file)
+ if err != nil {
+ return "", fmt.Errorf("reading key file %q: %w", file, err)
+ }
+ return string(data), nil
+ }
+}
diff --git a/cmd/deploykeys/create_test.go b/cmd/deploykeys/create_test.go
new file mode 100644
index 00000000..041efc09
--- /dev/null
+++ b/cmd/deploykeys/create_test.go
@@ -0,0 +1,110 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykeys
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "gitea.dev/sdk"
+ "github.com/stretchr/testify/assert"
+ "github.com/urfave/cli/v3"
+)
+
+func TestCreateCommandMetadata(t *testing.T) {
+ cmd := &CmdDeployKeysCreate
+
+ assert.Equal(t, "create", cmd.Name)
+ for _, want := range []string{"add", "c"} {
+ assert.Contains(t, cmd.Aliases, want)
+ }
+ assert.Equal(t, "Create a deploy key", cmd.Usage)
+ assert.Equal(t, "", cmd.ArgsUsage)
+ assert.NotNil(t, cmd.Action)
+}
+
+func TestCreateCommandFlags(t *testing.T) {
+ cmd := &CmdDeployKeysCreate
+
+ have := make(map[string]bool, len(cmd.Flags))
+ for _, flag := range cmd.Flags {
+ have[flag.Names()[0]] = true
+ }
+ for _, name := range []string{"key", "key-file", "read-only", "read-write", "login", "repo", "remote", "output"} {
+ assert.True(t, have[name], "expected flag %q not found", name)
+ }
+}
+
+func TestCreateReadOnlyDefault(t *testing.T) {
+ // The --read-only flag must default to true to match Gitea's "safe" default
+ // and the flag-help wording ("(default)").
+ for _, flag := range CmdDeployKeysCreate.Flags {
+ if bf, ok := flag.(*cli.BoolFlag); ok && bf.Name == "read-only" {
+ assert.True(t, bf.Value, "--read-only should default to true")
+ }
+ }
+}
+
+func TestResolveKeyMaterial(t *testing.T) {
+ const inlineKey = "ssh-ed25519 AAAA-test-key inline"
+
+ t.Run("inline key", func(t *testing.T) {
+ got, err := resolveKeyMaterial(inlineKey, "")
+ assert.NoError(t, err)
+ assert.Equal(t, inlineKey, got)
+ })
+
+ t.Run("mutually exclusive flags", func(t *testing.T) {
+ _, err := resolveKeyMaterial(inlineKey, "/tmp/key.pub")
+ assert.Error(t, err)
+ })
+
+ t.Run("neither flag set", func(t *testing.T) {
+ _, err := resolveKeyMaterial("", "")
+ assert.Error(t, err)
+ })
+
+ t.Run("read key from file", func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "key.pub")
+ want := inlineKey + " trailing-newline\n"
+ require := assert.New(t)
+ require.NoError(os.WriteFile(path, []byte(want), 0o600))
+
+ got, err := resolveKeyMaterial("", path)
+ require.NoError(err)
+ require.Equal(want, got)
+ })
+
+ t.Run("missing file", func(t *testing.T) {
+ _, err := resolveKeyMaterial("", filepath.Join(t.TempDir(), "does-not-exist.pub"))
+ assert.Error(t, err)
+ })
+}
+
+func TestCreateKeyOptionConstruction(t *testing.T) {
+ tests := []struct {
+ name string
+ title string
+ key string
+ readOnly bool
+ }{
+ {"read-only ed25519", "ci", "ssh-ed25519 AAAA...", true},
+ {"read-write rsa", "deploy-bot", "ssh-rsa AAAA...", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opt := gitea.CreateKeyOption{
+ Title: tt.title,
+ Key: tt.key,
+ ReadOnly: tt.readOnly,
+ }
+ assert.Equal(t, tt.title, opt.Title)
+ assert.Equal(t, tt.key, opt.Key)
+ assert.Equal(t, tt.readOnly, opt.ReadOnly)
+ })
+ }
+}
diff --git a/cmd/deploykeys/delete.go b/cmd/deploykeys/delete.go
new file mode 100644
index 00000000..770bf478
--- /dev/null
+++ b/cmd/deploykeys/delete.go
@@ -0,0 +1,74 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykeys
+
+import (
+ stdctx "context"
+ "errors"
+ "fmt"
+
+ "gitea.dev/tea/cmd/flags"
+ "gitea.dev/tea/modules/context"
+ "gitea.dev/tea/modules/utils"
+ "github.com/urfave/cli/v3"
+)
+
+// CmdDeployKeysDelete represents a sub command of deploy-keys to delete a deploy key
+var CmdDeployKeysDelete = cli.Command{
+ Name: "delete",
+ Aliases: []string{"rm"},
+ Usage: "Delete a deploy key",
+ Description: "Delete a deploy key by ID from a repository",
+ ArgsUsage: "",
+ Action: runDeployKeysDelete,
+ Flags: append([]cli.Flag{
+ &cli.BoolFlag{
+ Name: "confirm",
+ Aliases: []string{"y"},
+ Usage: "confirm deletion without prompting",
+ },
+ }, flags.AllDefaultFlags...),
+}
+
+func runDeployKeysDelete(ctx stdctx.Context, cmd *cli.Command) error {
+ if cmd.Args().Len() == 0 {
+ return errors.New("deploy key ID is required")
+ }
+
+ c, err := context.InitCommand(cmd)
+ if err != nil {
+ return err
+ }
+ if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
+ return err
+ }
+ client := c.Login.Client()
+
+ keyID, err := utils.ArgToIndex(cmd.Args().First())
+ if err != nil {
+ return fmt.Errorf("invalid deploy key ID: %w", err)
+ }
+
+ key, _, err := client.GetDeployKey(ctx, c.Owner, c.Repo, keyID)
+ if err != nil {
+ return err
+ }
+
+ if !cmd.Bool("confirm") {
+ fmt.Printf("Are you sure you want to delete deploy key %d (%s)? [y/N] ", key.ID, key.Title)
+ var response string
+ fmt.Scanln(&response)
+ if response != "y" && response != "Y" && response != "yes" {
+ fmt.Println("Deletion canceled.")
+ return nil
+ }
+ }
+
+ if _, err = client.DeleteDeployKey(ctx, c.Owner, c.Repo, keyID); err != nil {
+ return err
+ }
+
+ fmt.Printf("Deploy key %d deleted successfully\n", keyID)
+ return nil
+}
diff --git a/cmd/deploykeys/delete_test.go b/cmd/deploykeys/delete_test.go
new file mode 100644
index 00000000..02439d82
--- /dev/null
+++ b/cmd/deploykeys/delete_test.go
@@ -0,0 +1,112 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykeys
+
+import (
+ "strconv"
+ "testing"
+
+ "gitea.dev/sdk"
+ "gitea.dev/tea/modules/utils"
+ "github.com/stretchr/testify/assert"
+ "github.com/urfave/cli/v3"
+)
+
+func TestDeleteCommandMetadata(t *testing.T) {
+ cmd := &CmdDeployKeysDelete
+
+ assert.Equal(t, "delete", cmd.Name)
+ assert.Contains(t, cmd.Aliases, "rm")
+ assert.Equal(t, "Delete a deploy key", cmd.Usage)
+ assert.Equal(t, "Delete a deploy key by ID from a repository", cmd.Description)
+ assert.Equal(t, "", cmd.ArgsUsage)
+ assert.NotNil(t, cmd.Action)
+}
+
+func TestDeleteCommandFlags(t *testing.T) {
+ cmd := &CmdDeployKeysDelete
+
+ var confirmFlag *cli.BoolFlag
+ for _, flag := range cmd.Flags {
+ if flag.Names()[0] == "confirm" {
+ confirmFlag, _ = flag.(*cli.BoolFlag)
+ break
+ }
+ }
+ assert.NotNil(t, confirmFlag, "confirm flag should exist")
+ assert.Contains(t, confirmFlag.Aliases, "y")
+}
+
+func TestDeleteConfirmationLogic(t *testing.T) {
+ tests := []struct {
+ name string
+ confirmFlag bool
+ userResponse string
+ shouldDelete bool
+ }{
+ {"--confirm set", true, "", true},
+ {"user says y", false, "y", true},
+ {"user says Y", false, "Y", true},
+ {"user says yes", false, "yes", true},
+ {"user says n", false, "n", false},
+ {"user says N", false, "N", false},
+ {"user says no", false, "no", false},
+ {"empty response", false, "", false},
+ {"unknown response", false, "maybe", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ shouldDelete := tt.confirmFlag
+ if !tt.confirmFlag {
+ r := tt.userResponse
+ shouldDelete = r == "y" || r == "Y" || r == "yes"
+ }
+ assert.Equal(t, tt.shouldDelete, shouldDelete)
+ })
+ }
+}
+
+func TestDeleteKeyIDParsing(t *testing.T) {
+ // Mirrors utils.ArgToIndex behavior, exercised through the same parsing
+ // the command would receive.
+ tests := []struct {
+ name string
+ input string
+ wantID int64
+ expectErr bool
+ }{
+ {"single digit", "1", 1, false},
+ {"multi digit", "123", 123, false},
+ {"with hash prefix", "#42", 42, false},
+ {"zero", "0", 0, false},
+ {"negative", "-1", -1, false},
+ {"non-numeric", "abc", 0, true},
+ {"float", "12.5", 0, true},
+ {"empty", "", 0, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ id, err := utils.ArgToIndex(tt.input)
+ if tt.expectErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, tt.wantID, id)
+ })
+ }
+}
+
+func TestDeletePromptMessage(t *testing.T) {
+ key := &gitea.DeployKey{ID: 7, Title: "ci-runner"}
+
+ // The prompt must include both the ID and the title so the user can
+ // confirm which key is being deleted.
+ msg := "Are you sure you want to delete deploy key " + strconv.FormatInt(key.ID, 10) + " (" + key.Title + ")? [y/N] "
+ assert.Contains(t, msg, "7")
+ assert.Contains(t, msg, "ci-runner")
+ assert.Contains(t, msg, "[y/N]")
+}
diff --git a/cmd/deploykeys/list.go b/cmd/deploykeys/list.go
new file mode 100644
index 00000000..35d483ce
--- /dev/null
+++ b/cmd/deploykeys/list.go
@@ -0,0 +1,59 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykeys
+
+import (
+ stdctx "context"
+
+ gitea "gitea.dev/sdk"
+
+ "gitea.dev/tea/cmd/flags"
+ "gitea.dev/tea/modules/context"
+ "gitea.dev/tea/modules/print"
+ "github.com/urfave/cli/v3"
+)
+
+// CmdDeployKeysList represents a sub command of deploy-keys to list deploy keys
+var CmdDeployKeysList = cli.Command{
+ Name: "list",
+ Aliases: []string{"ls"},
+ Usage: "List deploy keys",
+ Description: "List deploy keys of a repository",
+ Action: RunDeployKeysList,
+ Flags: append([]cli.Flag{
+ &flags.PaginationPageFlag,
+ &flags.PaginationLimitFlag,
+ &cli.StringFlag{
+ Name: "fingerprint",
+ Usage: "filter by fingerprint",
+ },
+ }, flags.AllDefaultFlags...),
+}
+
+// RunDeployKeysList lists deploy keys
+func RunDeployKeysList(ctx stdctx.Context, cmd *cli.Command) error {
+ c, err := context.InitCommand(cmd)
+ if err != nil {
+ return err
+ }
+ if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
+ return err
+ }
+ client := c.Login.Client()
+
+ opts := gitea.ListDeployKeysOptions{
+ ListOptions: flags.GetListOptions(cmd),
+ }
+ if fp := cmd.String("fingerprint"); fp != "" {
+ opts.Fingerprint = fp
+ }
+
+ keys, _, err := client.ListDeployKeys(ctx, c.Owner, c.Repo, opts)
+ if err != nil {
+ return err
+ }
+
+ print.DeployKeysList(keys, c.Output)
+ return nil
+}
diff --git a/cmd/deploykeys/list_test.go b/cmd/deploykeys/list_test.go
new file mode 100644
index 00000000..c2bd5f92
--- /dev/null
+++ b/cmd/deploykeys/list_test.go
@@ -0,0 +1,132 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykeys
+
+import (
+ "testing"
+
+ "gitea.dev/sdk"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestListCommandMetadata(t *testing.T) {
+ cmd := &CmdDeployKeysList
+
+ assert.Equal(t, "list", cmd.Name)
+ assert.Contains(t, cmd.Aliases, "ls")
+ assert.Equal(t, "List deploy keys", cmd.Usage)
+ assert.Equal(t, "List deploy keys of a repository", cmd.Description)
+ assert.NotNil(t, cmd.Action)
+}
+
+func TestListCommandFlags(t *testing.T) {
+ cmd := &CmdDeployKeysList
+
+ expectedFlags := []string{
+ "page",
+ "limit",
+ "fingerprint",
+ "login",
+ "repo",
+ "remote",
+ "output",
+ }
+
+ have := make(map[string]bool, len(cmd.Flags))
+ for _, flag := range cmd.Flags {
+ have[flag.Names()[0]] = true
+ }
+ for _, name := range expectedFlags {
+ assert.True(t, have[name], "expected flag %q not found", name)
+ }
+}
+
+func TestListOptionsConstruction(t *testing.T) {
+ tests := []struct {
+ name string
+ fingerprint string
+ page int
+ limit int
+ }{
+ {
+ name: "default options",
+ fingerprint: "",
+ page: 1,
+ limit: 30,
+ },
+ {
+ name: "with fingerprint filter",
+ fingerprint: "SHA256:abc123",
+ page: 1,
+ limit: 30,
+ },
+ {
+ name: "with custom pagination",
+ fingerprint: "",
+ page: 3,
+ limit: 50,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opts := gitea.ListDeployKeysOptions{
+ ListOptions: gitea.ListOptions{
+ Page: tt.page,
+ PageSize: tt.limit,
+ },
+ }
+ if tt.fingerprint != "" {
+ opts.Fingerprint = tt.fingerprint
+ }
+ assert.Equal(t, tt.page, opts.Page)
+ assert.Equal(t, tt.limit, opts.PageSize)
+ assert.Equal(t, tt.fingerprint, opts.Fingerprint)
+ })
+ }
+}
+
+func TestListCommandStructure(t *testing.T) {
+ cmd := &CmdDeployKeysList
+
+ assert.NotEmpty(t, cmd.Name)
+ assert.NotEmpty(t, cmd.Usage)
+ assert.NotEmpty(t, cmd.Description)
+ assert.NotNil(t, cmd.Action)
+
+ for _, alias := range cmd.Aliases {
+ assert.NotEmpty(t, alias)
+ assert.NotContains(t, alias, " ")
+ }
+}
+
+func TestListOutputFormats(t *testing.T) {
+ // Same output formats as the rest of tea (see flags.OutputFlag)
+ supportedFormats := []string{
+ "table", "csv", "tsv", "simple", "yaml", "json",
+ }
+ for _, format := range supportedFormats {
+ t.Run("Format_"+format, func(t *testing.T) {
+ assert.NotEmpty(t, format)
+ assert.NotContains(t, format, " ")
+ })
+ }
+}
+
+func TestListTableHeaders(t *testing.T) {
+ // Headers must match the columns emitted by print.DeployKeysList.
+ expectedHeaders := []string{
+ "ID",
+ "Title",
+ "Fingerprint",
+ "Read-Only",
+ "Created",
+ }
+
+ headerSet := make(map[string]bool, len(expectedHeaders))
+ for _, h := range expectedHeaders {
+ assert.False(t, headerSet[h], "duplicate header %q", h)
+ headerSet[h] = true
+ }
+}
diff --git a/docs/CLI.md b/docs/CLI.md
index 2363574b..6a0fb0d2 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -2015,6 +2015,70 @@ Delete one or more comments by ID
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
+## deploy-keys, dk, deploy-key
+
+Manage repository deploy keys
+
+**--login, -l**="": Use a different Gitea Login. Optional
+
+**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
+
+**--remote, -R**="": Discover Gitea login from remote. Optional
+
+**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
+
+### list, ls
+
+List deploy keys
+
+**--fingerprint**="": filter by fingerprint
+
+**--limit, --lm**="": specify limit of items per page (default: 30)
+
+**--login, -l**="": Use a different Gitea Login. Optional
+
+**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
+
+**--page, -p**="": specify page (default: 1)
+
+**--remote, -R**="": Discover Gitea login from remote. Optional
+
+**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
+
+### create, add, c
+
+Create a deploy key
+
+**--key**="": inline armored SSH public key
+
+**--key-file**="": path to a file containing the armored SSH public key (e.g. ~/.ssh/id_ed25519.pub)
+
+**--login, -l**="": Use a different Gitea Login. Optional
+
+**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
+
+**--read-only**: restrict the key to read-only access (default)
+
+**--read-write**: grant the key read/write access
+
+**--remote, -R**="": Discover Gitea login from remote. Optional
+
+**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
+
+### delete, rm
+
+Delete a deploy key
+
+**--confirm, -y**: confirm deletion without prompting
+
+**--login, -l**="": Use a different Gitea Login. Optional
+
+**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
+
+**--remote, -R**="": Discover Gitea login from remote. Optional
+
+**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
+
## open, o
Open something of the repository in web browser
diff --git a/modules/print/deploy_key.go b/modules/print/deploy_key.go
new file mode 100644
index 00000000..3be86052
--- /dev/null
+++ b/modules/print/deploy_key.go
@@ -0,0 +1,52 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package print
+
+import (
+ "fmt"
+ "strconv"
+
+ "gitea.dev/sdk"
+)
+
+// DeployKeysList prints a listing of deploy keys
+func DeployKeysList(keys []*gitea.DeployKey, output string) {
+ t := tableWithHeader(
+ "ID",
+ "Title",
+ "Fingerprint",
+ "Read-Only",
+ "Created",
+ )
+
+ for _, key := range keys {
+ readOnly := "no"
+ if key.ReadOnly {
+ readOnly = "yes"
+ }
+
+ t.addRow(
+ strconv.FormatInt(key.ID, 10),
+ key.Title,
+ key.Fingerprint,
+ readOnly,
+ FormatTime(key.Created, false),
+ )
+ }
+
+ t.print(output)
+}
+
+// DeployKeyDetails prints detailed information about a deploy key
+func DeployKeyDetails(key *gitea.DeployKey) {
+ fmt.Printf("# Deploy Key %d\n\n", key.ID)
+ fmt.Printf("- **Title**: %s\n", key.Title)
+ fmt.Printf("- **Key ID**: %d\n", key.KeyID)
+ fmt.Printf("- **Fingerprint**: %s\n", key.Fingerprint)
+ fmt.Printf("- **Read-Only**: %t\n", key.ReadOnly)
+ fmt.Printf("- **Created**: %s\n", FormatTime(key.Created, false))
+ if key.URL != "" {
+ fmt.Printf("- **URL**: %s\n", key.URL)
+ }
+}