Repository: git-town/git-town

Issue:
GitHub API: delegate calls to GitHub CLI tool if installed
[CLI](https://cli.github.com/) for GitHub has reached 1.0 recently and it suggests a very decent and handy workflow for creating/merging PR from the terminal. I think you may incorporate it into your workflow.

As a result, you can remove the auth configuration and pass it all to the gh side.
Also removing a feature branch after merge can be made by gh tool (-d flag for `gh pr merge`).
  

Discussion:
@kevgo: Thanks for the tip @alphatroya! GitHub CLI is a great product. Git Town supports GitHub, BitBucket, Gitea, and other code hosting platforms. Using the GitHub CLI would only work for GitHub and probably be more complex than what we have right now. I'm also not comfortable requiring our users to install another CLI binary (GitHub CLI) besides Git Town. In that case we have to be concerned which version of it our users have installed and pester them to upgrade. Better to have everything needed inside the Git Town binary.

Since the GitHub CLI is also written in Go, we might use some of it's internals inside Git Town, once that codebase is stable...@kevgo: Detect presence of the GH CLI tool and use it if possible. Alternatively, try to use their [auth](https://cli.github.com/manual/gh_auth_login) workflow in Git Town.@kevgo: Concern: If `gh` makes a breaking change to their CLI arguments, it would break Git Town's integration with it.@kevgo: @tranhl wrote in https://github.com/git-town/git-town/issues/4089#issue-2562896942:

> The only way to configure a user's GitHub token currently is via Git metadata. This creates a few challenges:
> 
> * **The user has to create & manage their own PATs**: This is inherently insecure as it opens up the process to human error.
> * **The GitHub token is stored on disk:** This is also inherently insecure as secrets are stored in plaintext & open for attack.
> 
> I'd like to suggest using the `gh` CLI to generate & consume access tokens for use in Git Town. This is possible with the [gh auth token](https://cli.github.com/manual/gh_auth_token) command. Generating tokens on the fly would solve the above issues of exposing secure credentials on local machines & also eliminate the hassle of managing PATs. We could introduce this behind a setting and make it opt-in. Thoughts?
@kevgo: @tranhl do you know if the auth token returned by `gh auth token` provides permissions like editing pull requests?@tranhl: @kevgo I haven't been able to find much documentation on the token's permissions. The only thing that's evident is that it's an OAuth token instead of a PAT, which tells me that it might just inherit all of our permissions. I'll test it against the GitHub API once I find a bit of spare time.@kevgo: `gh` token scopes on my machine: `gist`, `read:org`, `repo`. Git Town needs the `repo` scope, so we should be good here.@kevgo: This will be implemented as a new connector type. Rather than making API requests, this connector shells out to the `gh` tool. Let's call it the "gh connector".

How would the user select this connector? Possible alternatives:

#### Option 1: forge type, then optionally connector type for forge.

1. select the forge type: "github"
2. Since for GitHub there are now two connectors, choose the GitHub connector to use: api or gh
3. Configure the chosen connector: enter API token or whatever

#### Option 2: just select the connector to use

1. the choice lists all connector types that Git Town supports, including the API and the gh connector
2. configure the chosen connector@tranhl: > This will be implemented as a new connector type. Rather than making API requests, this connector shells out to the `gh` tool. Let's call it the "gh connector".
> 
> How would the user select this connector? Possible alternatives:
> 
> #### Option 1: forge type, then optionally connector type for forge.
> 1. select the forge type: "github"
> 2. Since for GitHub there are now two connectors, choose the GitHub connector to use: api or gh
> 3. Configure the chosen connector: enter API token or whatever
> 
> #### Option 2: just select the connector to use
> 1. the choice lists all connector types that Git Town supports, including the API and the gh connector
> 2. configure the chosen connector

@kevgo As a user what makes sense for me would be to:

1. Be able to specify which forge the current repository uses.
2. Be able directly configure how credentials are obtained for that forge:
   a. Explicitly with the token itself (the current behaviour)
   b. With a shell command that Git Town can run (e.g. `gh auth token`)

That way if I ever end up using some other CLI tool or process to manage my tokens (e.g. managing them with the 1Password CLI instead) all I'd need to do is update the shell command accordingly.

This has the added benefit of being able to interface with any "connector" that has a CLI interface and also not needing to handle connector-specific quirks within Git Town itself.@kevgo: Thanks @tranhl, these are all great points!

I especially like the idea of supporting a shell command to retrieve the auth token. There are many secure ways users might store and access tokens: gh CLI, 1Password CLI, Bitwarden CLI, environment variables, or a shell script from a secure source. All of these are significantly better than storing credentials in plain text inside Git metadata!

I also agree it's a good idea to separate the forge type (step 1) from the details of how to connect (step 2), such as the connector implementation or token retrieval method. The forge type is always shared across the whole team, can often be auto-detected, and fits well in the Git Town config file. Token retrieval is often user-specific and shouldn’t be committed to shared config.

Solution patch:
diff --git a/features/config/color.ui/always.feature b/features/config/color.ui/always.feature
index 64b7f9c40..9c7130344 100644
--- a/features/config/color.ui/always.feature
+++ b/features/config/color.ui/always.feature
@@ -25,6 +25,7 @@ Feature: show the configuration
     And Git setting "git-town.unknown-branch-type" is "observed"
     And Git setting "git-town.feature-regex" is "^user-.*$"
     And Git setting "git-town.ship-strategy" is "squash-merge"
+    And Git setting "git-town.github-connector" is "api"
     When I run "git-town config"
     Then Git Town prints:
       """
@@ -56,6 +57,7 @@ Feature: show the configuration
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: api
         GitHub token: (not set)
         GitLab token: (not set)
 
@@ -133,6 +135,7 @@ Feature: show the configuration
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: (not set)
         GitHub token: (not set)
         GitLab token: (not set)
 
@@ -225,6 +228,7 @@ Feature: show the configuration
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: (not set)
         GitHub token: (not set)
         GitLab token: (not set)
 
@@ -281,6 +285,7 @@ Feature: show the configuration
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: (not set)
         GitHub token: (not set)
         GitLab token: (not set)
 
@@ -343,6 +348,7 @@ Feature: show the configuration
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: (not set)
         GitHub token: (not set)
         GitLab token: (not set)
 
diff --git a/features/config/setup/change_git_metadata.feature b/features/config/setup/change_git_metadata.feature
index aaf7fdcd8..21f9223cc 100644
--- a/features/config/setup/change_git_metadata.feature
+++ b/features/config/setup/change_git_metadata.feature
@@ -24,6 +24,7 @@ Feature: change existing information in Git metadata
       | dev-remote                                | enter                  |
       | origin hostname                           | c o d e enter          |
       | set forge type to "github"                | up up enter            |
+      | set github forge type to "API"            | enter                  |
       | github token                              | 1 2 3 4 5 6 enter      |
       | token scope                               | enter                  |
       | sync-feature-strategy                     | down enter             |
@@ -58,6 +59,7 @@ Feature: change existing information in Git metadata
       | git config --global alias.sync "town sync"               |
       | git config --local git-town.github-token 123456          |
       | git config git-town.forge-type github                    |
+      | git config git-town.github-connector api                 |
       | git config git-town.hosting-origin-hostname code         |
     And global Git setting "alias.append" is now "town append"
     And global Git setting "alias.diff-parent" is now "town diff-parent"
diff --git a/features/config/setup/github_token.feature b/features/config/setup/github_token.feature
index 54a73e5a3..076705648 100644
--- a/features/config/setup/github_token.feature
+++ b/features/config/setup/github_token.feature
@@ -18,6 +18,7 @@ Feature: enter the GitHub API token
       | dev-remote                  | enter             |                                             |
       | origin hostname             | enter             |                                             |
       | forge type: auto-detect     | enter             |                                             |
+      | github connector type: API  | enter             |                                             |
       | github token                | 1 2 3 4 5 6 enter |                                             |
       | token scope                 | enter             |                                             |
       | sync-feature-strategy       | enter             |                                             |
@@ -34,6 +35,7 @@ Feature: enter the GitHub API token
     Then Git Town runs the commands
       | COMMAND                                         |
       | git config --local git-town.github-token 123456 |
+      | git config git-town.github-connector api        |
     And local Git setting "git-town.forge-type" still doesn't exist
     And local Git setting "git-town.github-token" is now "123456"
 
@@ -50,6 +52,7 @@ Feature: enter the GitHub API token
       | dev-remote                  | enter                          |                                             |
       | origin hostname             | enter                          |                                             |
       | forge type                  | down down down down down enter |                                             |
+      | github connector type: API  | enter                          |                                             |
       | github token                | 1 2 3 4 5 6 enter              |                                             |
       | token scope                 | enter                          |                                             |
       | sync-feature-strategy       | enter                          |                                             |
@@ -67,6 +70,7 @@ Feature: enter the GitHub API token
       | COMMAND                                         |
       | git config --local git-town.github-token 123456 |
       | git config git-town.forge-type github           |
+      | git config git-town.github-connector api        |
     And local Git setting "git-town.forge-type" is now "github"
     And local Git setting "git-town.github-token" is now "123456"
 
@@ -85,6 +89,7 @@ Feature: enter the GitHub API token
       | dev-remote                  | enter                               |                                             |
       | origin hostname             | enter                               |                                             |
       | forge type: auto-detect     | enter                               |                                             |
+      | github connector type: API  | enter                               |                                             |
       | github token                | backspace backspace backspace enter |                                             |
       | sync-feature-strategy       | enter                               |                                             |
       | sync-perennial-strategy     | enter                               |                                             |
@@ -100,6 +105,7 @@ Feature: enter the GitHub API token
     Then Git Town runs the commands
       | COMMAND                                  |
       | git config --unset git-town.github-token |
+      | git config git-town.github-connector api |
     And local Git setting "git-town.forge-type" still doesn't exist
     And local Git setting "git-town.github-token" now doesn't exist
 
@@ -117,6 +123,7 @@ Feature: enter the GitHub API token
       | dev-remote                  | enter             |                                             |
       | origin hostname             | enter             |                                             |
       | forge type                  | enter             |                                             |
+      | github connector type: API  | enter             |                                             |
       | github token                | 1 2 3 4 5 6 enter |                                             |
       | token scope                 | down enter        |                                             |
       | sync-feature-strategy       | enter             |                                             |
@@ -133,6 +140,7 @@ Feature: enter the GitHub API token
     Then Git Town runs the commands
       | COMMAND                                          |
       | git config --global git-town.github-token 123456 |
+      | git config git-town.github-connector api         |
     And global Git setting "git-town.github-token" is now "123456"
 
   Scenario: edit global GitHub token
@@ -150,6 +158,7 @@ Feature: enter the GitHub API token
       | dev-remote                  | enter                                     |                                             |
       | origin hostname             | enter                                     |                                             |
       | forge type                  | enter                                     |                                             |
+      | github connector type: API  | enter                                     |                                             |
       | github token                | backspace backspace backspace 4 5 6 enter |                                             |
       | token scope                 | enter                                     |                                             |
       | sync-feature-strategy       | enter                                     |                                             |
@@ -166,4 +175,5 @@ Feature: enter the GitHub API token
     Then Git Town runs the commands
       | COMMAND                                       |
       | git config --global git-town.github-token 456 |
+      | git config git-town.github-connector api      |
     And global Git setting "git-town.github-token" is now "456"
diff --git a/features/config/setup/global_token_other_forge.feature b/features/config/setup/global_token_other_forge.feature
index 3295c87ce..75aee077f 100644
--- a/features/config/setup/global_token_other_forge.feature
+++ b/features/config/setup/global_token_other_forge.feature
@@ -17,6 +17,7 @@ Feature: a global API token of another forge exists
       | dev-remote                  | enter             |                                             |
       | origin hostname             | enter             |                                             |
       | forge type: auto-detect     | enter             |                                             |
+      | github connector type: API  | enter             |                                             |
       | github token                | 1 2 3 4 5 6 enter |                                             |
       | token scope                 | enter             |                                             |
       | sync-feature-strategy       | enter             |                                             |
@@ -33,6 +34,7 @@ Feature: a global API token of another forge exists
     Then Git Town runs the commands
       | COMMAND                                         |
       | git config --local git-town.github-token 123456 |
+      | git config git-town.github-connector api        |
     And local Git setting "git-town.forge-type" still doesn't exist
     And local Git setting "git-town.github-token" is now "123456"
     And local Git setting "git-town.gitlab-token" now doesn't exist
diff --git a/features/config/setup/migrate_git_to_file.feature b/features/config/setup/migrate_git_to_file.feature
index f2d10a375..59dee9857 100644
--- a/features/config/setup/migrate_git_to_file.feature
+++ b/features/config/setup/migrate_git_to_file.feature
@@ -29,7 +29,7 @@ Feature: migrate existing configuration in Git metadata to a config file
       | unknown branch type                       | enter |
       | dev-remote                                | enter |
       | origin hostname                           | enter |
-      | hosting service                           | enter |
+      | forge type                                | enter |
       | sync-feature-strategy                     | enter |
       | sync-perennial-strategy                   | enter |
       | sync-prototype-strategy                   | enter |
diff --git a/features/config/setup/remove_git_metadata.feature b/features/config/setup/remove_git_metadata.feature
index 1cb8af217..d17053d74 100644
--- a/features/config/setup/remove_git_metadata.feature
+++ b/features/config/setup/remove_git_metadata.feature
@@ -48,7 +48,7 @@ Feature: remove existing configuration in Git metadata
       | unknown branch type                     | down enter                                                        |
       | dev-remote                              | enter                                                             |
       | remove origin hostname                  | backspace backspace backspace backspace enter                     |
-      | remove hosting service override         | up up up up up enter                                              |
+      | remove forge type override              | up up up up up enter                                              |
       | sync-feature-strategy                   | down enter                                                        |
       | sync-perennial-strategy                 | down enter                                                        |
       | sync-prototype-strategy                 | down enter                                                        |
diff --git a/features/config/setup/unconfigured_with_config_file.feature b/features/config/setup/unconfigured_with_config_file.feature
index 05a108c71..bce0d3df2 100644
--- a/features/config/setup/unconfigured_with_config_file.feature
+++ b/features/config/setup/unconfigured_with_config_file.feature
@@ -44,12 +44,14 @@ Feature: don't ask for information already provided by the config file
       | DIALOG                     | KEYS              |
       | welcome                    | enter             |
       | aliases                    | enter             |
+      | github connector type: API | enter             |
       | GitHub token               | 1 2 3 4 5 6 enter |
       | token scope: local         | enter             |
       | save config to config file | down enter        |
     Then Git Town runs the commands
       | COMMAND                                         |
       | git config --local git-town.github-token 123456 |
+      | git config git-town.github-connector api        |
     And there are still no perennial branches
     And local Git setting "git-town.dev-remote" still doesn't exist
     And local Git setting "git-town.new-branch-type" still doesn't exist
diff --git a/features/config/setup/unconfigured_with_config_file_and_global_api_token.feature b/features/config/setup/unconfigured_with_config_file_and_global_api_token.feature
index f1a73108e..96860c174 100644
--- a/features/config/setup/unconfigured_with_config_file_and_global_api_token.feature
+++ b/features/config/setup/unconfigured_with_config_file_and_global_api_token.feature
@@ -24,6 +24,7 @@ Feature: don't ask for information already provided by the config file
       dev-remote = "origin"
       origin-hostname = "github.com"
       forge-type = "github"
+      github-connector = "gh"
 
       [ship]
       delete-tracking-branch = true
diff --git a/features/config/view/alternative_config_file.feature b/features/config/view/alternative_config_file.feature
index 658688056..5a5855ce5 100644
--- a/features/config/view/alternative_config_file.feature
+++ b/features/config/view/alternative_config_file.feature
@@ -39,6 +39,7 @@ Feature: show the configuration when using an alternative config file
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: (not set)
         GitHub token: (not set)
         GitLab token: (not set)
 
diff --git a/features/config/view/happy_path.feature b/features/config/view/happy_path.feature
index ab01dc3b6..ed350ccaa 100644
--- a/features/config/view/happy_path.feature
+++ b/features/config/view/happy_path.feature
@@ -55,6 +55,7 @@ Feature: show the configuration
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: (not set)
         GitHub token: (not set)
         GitLab token: (not set)
 
@@ -88,6 +89,7 @@ Feature: show the configuration
 
       [hosting]
       forge-type = "github"
+      github-connector = "gh"
       origin-hostname = "github.com"
 
       [ship]
@@ -132,6 +134,7 @@ Feature: show the configuration
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: gh
         GitHub token: (not set)
         GitLab token: (not set)
 
@@ -155,6 +158,7 @@ Feature: show the configuration
     And Git setting "git-town.observed-regex" is "^git-observed-regex"
     And Git setting "git-town.perennial-regex" is "^git-perennial-"
     And Git setting "git-town.feature-regex" is "git-feature-.*"
+    And Git setting "git-town.github-connector" is "api"
     And Git setting "git-town.share-new-branches" is "no"
     And Git setting "git-town.ship-strategy" is "squash-merge"
     And Git setting "git-town.ship-delete-tracking-branch" is "false"
@@ -180,6 +184,7 @@ Feature: show the configuration
 
       [hosting]
       forge-type = "github"
+      github-connector = "gh"
       origin-hostname = "github.com"
 
       [ship]
@@ -224,6 +229,7 @@ Feature: show the configuration
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: api
         GitHub token: (not set)
         GitLab token: (not set)
 
@@ -280,6 +286,7 @@ Feature: show the configuration
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: (not set)
         GitHub token: (not set)
         GitLab token: (not set)
 
@@ -342,6 +349,7 @@ Feature: show the configuration
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: (not set)
         GitHub token: (not set)
         GitLab token: (not set)
 
diff --git a/features/config/view/in_subfolder.feature b/features/config/view/in_subfolder.feature
index 80c5009c9..a7a4d2436 100644
--- a/features/config/view/in_subfolder.feature
+++ b/features/config/view/in_subfolder.feature
@@ -40,6 +40,7 @@ Feature: show the configuration from a subfolder
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: (not set)
         GitHub token: (not set)
         GitLab token: (not set)
 
diff --git a/features/config/view/lang/es.feature b/features/config/view/lang/es.feature
index 42d9fd43a..8a2811f7d 100644
--- a/features/config/view/lang/es.feature
+++ b/features/config/view/lang/es.feature
@@ -55,6 +55,7 @@ Feature: show the configuration in Spanish
         Bitbucket app password: (not set)
         Codeberg token: (not set)
         Gitea token: (not set)
+        GitHub connector type: (not set)
         GitHub token: (not set)
         GitLab token: (not set)
 
diff --git a/internal/cli/dialog/github_connector_type.go b/internal/cli/dialog/github_connector_type.go
new file mode 100644
index 000000000..98d9bb3c9
--- /dev/null
+++ b/internal/cli/dialog/github_connector_type.go
@@ -0,0 +1,51 @@
+package dialog
+
+import (
+	"fmt"
+
+	"github.com/git-town/git-town/v21/internal/cli/dialog/components"
+	"github.com/git-town/git-town/v21/internal/cli/dialog/components/list"
+	"github.com/git-town/git-town/v21/internal/cli/dialog/dialogdomain"
+	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
+	"github.com/git-town/git-town/v21/internal/messages"
+	. "github.com/git-town/git-town/v21/pkg/prelude"
+)
+
+const (
+	githubConnectorTypeTitle = `GitHub connector type`
+	githubConnectorTypeHelp  = `
+Git Town supports two ways to connect to GitHub:
+
+1. GitHub API:
+   Git Town talks directly with the GitHub API.
+	 This uses an access token for your account
+	 that you provide on the next screen.
+2. GitHub's "gh" application:
+   This doesn't require an access token
+	 but you need to install and configure the gh tool.
+
+`
+)
+
+func GitHubConnectorType(existing Option[forgedomain.GitHubConnectorType], inputs components.TestInput) (forgedomain.GitHubConnectorType, dialogdomain.Exit, error) {
+	entries := list.Entries[forgedomain.GitHubConnectorType]{
+		{
+			Data: forgedomain.GitHubConnectorTypeAPI,
+			Text: "API token",
+		},
+		{
+			Data: forgedomain.GitHubConnectorTypeGh,
+			Text: "gh tool",
+		},
+	}
+	defaultPos := 0
+	if existingValue, hasExisting := existing.Get(); hasExisting {
+		defaultPos = entries.IndexOf(existingValue)
+	}
+	selection, exit, err := components.RadioList(entries, defaultPos, githubConnectorTypeTitle, githubConnectorTypeHelp, inputs)
+	if err != nil || exit {
+		return forgedomain.GitHubConnectorTypeAPI, exit, err
+	}
+	fmt.Printf(messages.GitHubConnectorType, components.FormattedSelection(selection.String(), exit))
+	return selection, exit, err
+}
diff --git a/internal/cmd/append.go b/internal/cmd/append.go
index ebde5df61..962c06ba2 100644
--- a/internal/cmd/append.go
+++ b/internal/cmd/append.go
@@ -255,7 +255,7 @@ func determineAppendData(targetBranch gitdomain.LocalBranchName, beam configdoma
 		return data, exit, errors.New(messages.CurrentBranchCannotDetermine)
 	}
 	branchesAndTypes := repo.UnvalidatedConfig.UnvalidatedBranchesAndTypes(branchesSnapshot.Branches.LocalBranches().Names())
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/compress.go b/internal/cmd/compress.go
index 5c69d2553..108da9d85 100644
--- a/internal/cmd/compress.go
+++ b/internal/cmd/compress.go
@@ -216,7 +216,7 @@ func determineCompressBranchesData(repo execute.OpenRepoResult, dryRun configdom
 	}
 	localBranches := branchesSnapshot.Branches.LocalBranches().Names()
 	branchesAndTypes := repo.UnvalidatedConfig.UnvalidatedBranchesAndTypes(branchesSnapshot.Branches.LocalBranches().Names())
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/config/root.go b/internal/cmd/config/root.go
index 8b767a579..dbabbfeca 100644
--- a/internal/cmd/config/root.go
+++ b/internal/cmd/config/root.go
@@ -85,6 +85,7 @@ func printConfig(config config.UnvalidatedConfig) {
 	print.Entry("Bitbucket app password", format.OptionalStringerSetting(config.NormalConfig.BitbucketAppPassword))
 	print.Entry("Codeberg token", format.OptionalStringerSetting(config.NormalConfig.CodebergToken))
 	print.Entry("Gitea token", format.OptionalStringerSetting(config.NormalConfig.GiteaToken))
+	print.Entry("GitHub connector type", format.OptionalStringerSetting(config.NormalConfig.GitHubConnectorType))
 	print.Entry("GitHub token", format.OptionalStringerSetting(config.NormalConfig.GitHubToken))
 	print.Entry("GitLab token", format.OptionalStringerSetting(config.NormalConfig.GitLabToken))
 	fmt.Println()
diff --git a/internal/cmd/config/setup.go b/internal/cmd/config/setup.go
index e32b9b045..59a3c0a77 100644
--- a/internal/cmd/config/setup.go
+++ b/internal/cmd/config/setup.go
@@ -21,6 +21,7 @@ import (
 	"github.com/git-town/git-town/v21/internal/forge/bitbucketdatacenter"
 	"github.com/git-town/git-town/v21/internal/forge/codeberg"
 	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
+	"github.com/git-town/git-town/v21/internal/forge/gh"
 	"github.com/git-town/git-town/v21/internal/forge/gitea"
 	"github.com/git-town/git-town/v21/internal/forge/github"
 	"github.com/git-town/git-town/v21/internal/forge/gitlab"
@@ -202,7 +203,7 @@ func enterData(repo execute.OpenRepoResult, data *setupData) (configdomain.Confi
 		if err != nil || exit {
 			return tokenScope, forgeTypeOpt, exit, err
 		}
-		repeat, exit, err := testForgeAuth(data, forgeTypeOpt)
+		repeat, exit, err := testForgeAuth(data, repo, forgeTypeOpt)
 		if err != nil || exit {
 			return tokenScope, forgeTypeOpt, exit, err
 		}
@@ -292,7 +293,18 @@ func enterForgeAuth(repo execute.OpenRepoResult, data *setupData) (forgeTypeOpt
 		case forgedomain.ForgeTypeGitea:
 			exit, err = enterGiteaToken(data, repo)
 		case forgedomain.ForgeTypeGitHub:
-			exit, err = enterGithubToken(data, repo)
+			existing := data.userInput.config.NormalConfig.GitHubConnectorType.Or(repo.UnvalidatedConfig.NormalConfig.GitHubConnectorType)
+			var answer forgedomain.GitHubConnectorType
+			answer, exit, err = dialog.GitHubConnectorType(existing, data.dialogInputs.Next())
+			if err != nil || exit {
+				return forgeTypeOpt, exit, err
+			}
+			data.userInput.config.NormalConfig.GitHubConnectorType = Some(answer)
+			switch answer {
+			case forgedomain.GitHubConnectorTypeAPI:
+				exit, err = enterGithubToken(data, repo)
+			case forgedomain.GitHubConnectorTypeGh:
+			}
 		case forgedomain.ForgeTypeGitLab:
 			exit, err = enterGitlabToken(data, repo)
 		}
@@ -335,8 +347,8 @@ func enterGitlabToken(data *setupData, repo execute.OpenRepoResult) (exit dialog
 	return exit, err
 }
 
-func testForgeAuth(data *setupData, forgeTypeOpt Option[forgedomain.ForgeType]) (repeat bool, exit dialogdomain.Exit, err error) {
-	connector, err := createConnector(data, forgeTypeOpt)
+func testForgeAuth(data *setupData, repo execute.OpenRepoResult, forgeTypeOpt Option[forgedomain.ForgeType]) (repeat bool, exit dialogdomain.Exit, err error) {
+	connector, err := createConnector(data, repo, forgeTypeOpt)
 	if err != nil {
 		return false, false, err
 	}
@@ -357,7 +369,7 @@ func testForgeAuth(data *setupData, forgeTypeOpt Option[forgedomain.ForgeType])
 	return false, false, nil
 }
 
-func createConnector(data *setupData, forgeTypeOpt Option[forgedomain.ForgeType]) (forgedomain.Connector, error) { //nolint:ireturn
+func createConnector(data *setupData, repo execute.OpenRepoResult, forgeTypeOpt Option[forgedomain.ForgeType]) (forgedomain.Connector, error) { //nolint:ireturn
 	if forgeType, hasForgeType := forgeTypeOpt.Get(); hasForgeType {
 		switch forgeType {
 		case forgedomain.ForgeTypeBitbucket:
@@ -395,11 +407,21 @@ func createConnector(data *setupData, forgeTypeOpt Option[forgedomain.ForgeType]
 				RemoteURL: data.config.NormalConfig.DevURL().GetOrDefault(),
 			}), nil
 		case forgedomain.ForgeTypeGitHub:
-			return github.NewConnector(github.NewConnectorArgs{
-				APIToken:  data.userInput.config.NormalConfig.GitHubToken,
-				Log:       print.Logger{},
-				RemoteURL: data.config.NormalConfig.DevURL().GetOrDefault(),
-			})
+			if connectorType, hasConnectorType := data.userInput.config.NormalConfig.GitHubConnectorType.Get(); hasConnectorType {
+				switch connectorType {
+				case forgedomain.GitHubConnectorTypeAPI:
+					return github.NewConnector(github.NewConnectorArgs{
+						APIToken:  data.userInput.config.NormalConfig.GitHubToken,
+						Log:       print.Logger{},
+						RemoteURL: data.config.NormalConfig.DevURL().GetOrDefault(),
+					})
+				case forgedomain.GitHubConnectorTypeGh:
+					return gh.Connector{
+						Backend:  repo.Backend,
+						Frontend: repo.Backend,
+					}, nil
+				}
+			}
 		case forgedomain.ForgeTypeGitLab:
 			return gitlab.NewConnector(gitlab.NewConnectorArgs{
 				APIToken:  data.userInput.config.NormalConfig.GitLabToken,
@@ -543,7 +565,7 @@ func saveAll(userInput userInput, oldConfig config.UnvalidatedConfig, configFile
 				return err
 			}
 		case forgedomain.ForgeTypeGitHub:
-			err = saveGitHubToken(oldConfig.NormalConfig.GitHubToken, userInput.config.NormalConfig.GitHubToken, tokenScope, gitCommands, frontend)
+			err = saveGitHubToken(oldConfig.NormalConfig.GitHubToken, userInput.config.NormalConfig.GitHubToken, tokenScope, userInput.config.NormalConfig.GitHubConnectorType, gitCommands, frontend)
 			if err != nil {
 				return err
 			}
@@ -577,6 +599,9 @@ func saveToGit(userInput userInput, oldConfig config.UnvalidatedConfig, configFi
 	if configFile.ForgeType.IsNone() {
 		fc.Check(saveForgeType(oldConfig.NormalConfig.ForgeType, userInput.config.NormalConfig.ForgeType, gitCommands, frontend))
 	}
+	if configFile.GitHubConnectorType.IsNone() {
+		fc.Check(saveGitHubConnectorType(oldConfig.NormalConfig.GitHubConnectorType, userInput.config.NormalConfig.GitHubConnectorType, gitCommands, frontend))
+	}
 	if configFile.HostingOriginHostname.IsNone() {
 		fc.Check(saveOriginHostname(oldConfig.NormalConfig.HostingOriginHostname, userInput.config.NormalConfig.HostingOriginHostname, gitCommands, frontend))
 	}
@@ -736,7 +761,22 @@ func saveGiteaToken(oldToken, newToken Option[configdomain.GiteaToken], scope co
 	return gitCommands.RemoveGiteaToken(frontend)
 }
 
-func saveGitHubToken(oldToken, newToken Option[configdomain.GitHubToken], scope configdomain.ConfigScope, gitCommands git.Commands, frontend subshelldomain.Runner) error {
+func saveGitHubConnectorType(oldType, newType Option[forgedomain.GitHubConnectorType], gitCommands git.Commands, frontend subshelldomain.Runner) error {
+	if newType.Equal(oldType) {
+		return nil
+	}
+	if value, has := newType.Get(); has {
+		return gitCommands.SetGitHubConnectorType(frontend, value)
+	}
+	return gitCommands.RemoveGitHubConnectorType(frontend)
+}
+
+func saveGitHubToken(oldToken, newToken Option[configdomain.GitHubToken], scope configdomain.ConfigScope, githubConnectorType Option[forgedomain.GitHubConnectorType], gitCommands git.Commands, frontend subshelldomain.Runner) error {
+	if connectorType, has := githubConnectorType.Get(); has {
+		if connectorType == forgedomain.GitHubConnectorTypeGh {
+			return nil
+		}
+	}
 	if newToken.Equal(oldToken) {
 		return nil
 	}
diff --git a/internal/cmd/continue.go b/internal/cmd/continue.go
index 4a11c681d..d732fbbeb 100644
--- a/internal/cmd/continue.go
+++ b/internal/cmd/continue.go
@@ -148,7 +148,7 @@ func determineContinueData(repo execute.OpenRepoResult, verbose configdomain.Ver
 			return data, false, errors.New(messages.CurrentBranchCannotDetermine)
 		}
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	return continueData{
 		branchesSnapshot: branchesSnapshot,
 		config:           validatedConfig,
diff --git a/internal/cmd/delete.go b/internal/cmd/delete.go
index b183d8796..80dbc52cc 100644
--- a/internal/cmd/delete.go
+++ b/internal/cmd/delete.go
@@ -200,7 +200,7 @@ func determineDeleteData(args []string, repo execute.OpenRepoResult, dryRun conf
 	if branchToDelete.SyncStatus == gitdomain.SyncStatusOtherWorktree {
 		return data, exit, fmt.Errorf(messages.BranchOtherWorktree, branchNameToDelete)
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/detach.go b/internal/cmd/detach.go
index 5e5c836e6..28254a724 100644
--- a/internal/cmd/detach.go
+++ b/internal/cmd/detach.go
@@ -211,7 +211,7 @@ func determineDetachData(args []string, repo execute.OpenRepoResult, dryRun conf
 	if branchToDetachInfo.SyncStatus == gitdomain.SyncStatusOtherWorktree {
 		return data, exit, fmt.Errorf(messages.BranchOtherWorktree, branchNameToDetach)
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/diff_parent.go b/internal/cmd/diff_parent.go
index 5ee8af24d..927874d3e 100644
--- a/internal/cmd/diff_parent.go
+++ b/internal/cmd/diff_parent.go
@@ -118,7 +118,7 @@ func determineDiffParentData(args []string, repo execute.OpenRepoResult, verbose
 	}
 	localBranches := branchesSnapshot.Branches.LocalBranches().Names()
 	branchesAndTypes := repo.UnvalidatedConfig.UnvalidatedBranchesAndTypes(branchesSnapshot.Branches.LocalBranches().Names())
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/hack.go b/internal/cmd/hack.go
index 31fa6f57a..c849b63df 100644
--- a/internal/cmd/hack.go
+++ b/internal/cmd/hack.go
@@ -293,7 +293,7 @@ func determineHackData(args []string, repo execute.OpenRepoResult, beam configdo
 			branchesToValidate = targetBranches
 		}
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/merge.go b/internal/cmd/merge.go
index 46d6e142c..5e3a2f89f 100644
--- a/internal/cmd/merge.go
+++ b/internal/cmd/merge.go
@@ -195,7 +195,7 @@ func determineMergeData(repo execute.OpenRepoResult, verbose configdomain.Verbos
 		return mergeData{}, false, errors.New(messages.CurrentBranchCannotDetermine)
 	}
 	branchesAndTypes := repo.UnvalidatedConfig.UnvalidatedBranchesAndTypes(branchesSnapshot.Branches.LocalBranches().Names())
-	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return mergeData{}, false, err
 	}
diff --git a/internal/cmd/prepend.go b/internal/cmd/prepend.go
index ac5838ec6..e689cfdd9 100644
--- a/internal/cmd/prepend.go
+++ b/internal/cmd/prepend.go
@@ -278,7 +278,7 @@ func determinePrependData(args []string, repo execute.OpenRepoResult, beam confi
 	if !hasInitialBranchInfo {
 		return data, false, errors.New(messages.CurrentBranchCannotDetermine)
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/propose.go b/internal/cmd/propose.go
index c3cbfe307..ec7f8cd31 100644
--- a/internal/cmd/propose.go
+++ b/internal/cmd/propose.go
@@ -225,7 +225,7 @@ func determineProposeData(repo execute.OpenRepoResult, dryRun configdomain.DryRu
 	if !hasInitialBranch {
 		return data, false, errors.New(messages.CurrentBranchCannotDetermine)
 	}
-	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/rename.go b/internal/cmd/rename.go
index b74bcad14..608b08091 100644
--- a/internal/cmd/rename.go
+++ b/internal/cmd/rename.go
@@ -186,7 +186,7 @@ func determineRenameData(args []string, force configdomain.Force, repo execute.O
 	if !hasOldBranch {
 		return data, false, fmt.Errorf(messages.BranchDoesntExist, oldBranchName)
 	}
-	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/repo.go b/internal/cmd/repo.go
index 0c89175e2..88a15ebdb 100644
--- a/internal/cmd/repo.go
+++ b/internal/cmd/repo.go
@@ -81,7 +81,7 @@ func determineRepoData(args []string, repo execute.OpenRepoResult) (data repoDat
 	if !hasRemote {
 		return repoData{connector: nil}, nil
 	}
-	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, remote, print.Logger{})
+	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, remote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, err
 	}
diff --git a/internal/cmd/set_parent.go b/internal/cmd/set_parent.go
index cb41c2165..9cdf683f3 100644
--- a/internal/cmd/set_parent.go
+++ b/internal/cmd/set_parent.go
@@ -201,7 +201,7 @@ func determineSetParentData(repo execute.OpenRepoResult, verbose configdomain.Ve
 	}
 	localBranches := branchesSnapshot.Branches.LocalBranches().Names()
 	branchesAndTypes := repo.UnvalidatedConfig.UnvalidatedBranchesAndTypes(branchesSnapshot.Branches.LocalBranches().Names())
-	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/ship/shared.go b/internal/cmd/ship/shared.go
index 680156f69..c03bd0fac 100644
--- a/internal/cmd/ship/shared.go
+++ b/internal/cmd/ship/shared.go
@@ -127,7 +127,7 @@ func determineSharedShipData(args []string, repo execute.OpenRepoResult, dryRun
 		return data, false, fmt.Errorf(messages.BranchDoesntExist, targetBranchName)
 	}
 	childBranches := validatedConfig.NormalConfig.Lineage.Children(branchNameToShip)
-	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connectorOpt, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/skip.go b/internal/cmd/skip.go
index 5c7eea1c5..d1fab941f 100644
--- a/internal/cmd/skip.go
+++ b/internal/cmd/skip.go
@@ -87,7 +87,7 @@ func executeSkip(verbose configdomain.Verbose) error {
 			return err
 		}
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return err
 	}
diff --git a/internal/cmd/swap.go b/internal/cmd/swap.go
index 9bb47edcb..604ccb070 100644
--- a/internal/cmd/swap.go
+++ b/internal/cmd/swap.go
@@ -208,7 +208,7 @@ func determineSwapData(args []string, repo execute.OpenRepoResult, dryRun config
 	if branchToSwapInfo.SyncStatus == gitdomain.SyncStatusOtherWorktree {
 		return data, exit, fmt.Errorf(messages.BranchOtherWorktree, branchNameToSwap)
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/sync/cmd.go b/internal/cmd/sync/cmd.go
index 13ec98b0f..40d4036cc 100644
--- a/internal/cmd/sync/cmd.go
+++ b/internal/cmd/sync/cmd.go
@@ -275,7 +275,7 @@ func determineSyncData(syncAllBranches configdomain.AllBranches, syncStack confi
 	if !hasInitialBranch {
 		return data, false, errors.New(messages.CurrentBranchCannotDetermine)
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/undo.go b/internal/cmd/undo.go
index e52f8eb3b..41664708b 100644
--- a/internal/cmd/undo.go
+++ b/internal/cmd/undo.go
@@ -70,7 +70,7 @@ func executeUndo(verbose configdomain.Verbose) error {
 		fmt.Println(messages.UndoNothingToDo)
 		return nil
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return err
 	}
@@ -128,7 +128,7 @@ func determineUndoData(repo execute.OpenRepoResult, verbose configdomain.Verbose
 	if err != nil || exit {
 		return data, false, err
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return data, false, err
 	}
diff --git a/internal/cmd/walk.go b/internal/cmd/walk.go
index 542ab3d41..0472dd109 100644
--- a/internal/cmd/walk.go
+++ b/internal/cmd/walk.go
@@ -211,7 +211,7 @@ func determineWalkData(all configdomain.AllBranches, dryRun configdomain.DryRun,
 	if !hasInitialBranch {
 		return walkData{}, false, errors.New(messages.CurrentBranchCannotDetermine)
 	}
-	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{}, repo.Frontend, repo.Backend)
 	if err != nil {
 		return walkData{}, false, err
 	}
diff --git a/internal/config/configdomain/key.go b/internal/config/configdomain/key.go
index 2a0a5384e..98a3477b4 100644
--- a/internal/config/configdomain/key.go
+++ b/internal/config/configdomain/key.go
@@ -82,6 +82,7 @@ const (
 	KeyFeatureRegex                        = Key("git-town.feature-regex")
 	KeyForgeType                           = Key("git-town.forge-type")
 	KeyGiteaToken                          = Key("git-town.gitea-token")
+	KeyGitHubConnectorType                 = Key("git-town.github-connector")
 	KeyGithubToken                         = Key(pkg.KeyGithubToken)
 	KeyGitlabToken                         = Key("git-town.gitlab-token")
 	KeyHostingOriginHostname               = Key("git-town.hosting-origin-hostname")
@@ -136,6 +137,7 @@ var keys = []Key{
 	KeyFeatureRegex,
 	KeyForgeType,
 	KeyGiteaToken,
+	KeyGitHubConnectorType,
 	KeyGithubToken,
 	KeyGitlabToken,
 	KeyGitUserEmail,
diff --git a/internal/config/configdomain/normal_config_data.go b/internal/config/configdomain/normal_config_data.go
index 6f8877c7f..15036961e 100644
--- a/internal/config/configdomain/normal_config_data.go
+++ b/internal/config/configdomain/normal_config_data.go
@@ -20,6 +20,7 @@ type NormalConfigData struct {
 	DevRemote                gitdomain.Remote
 	FeatureRegex             Option[FeatureRegex]
 	ForgeType                Option[forgedomain.ForgeType] // Some = override by user, None = auto-detect
+	GitHubConnectorType      Option[forgedomain.GitHubConnectorType]
 	GitHubToken              Option[GitHubToken]
 	GitLabToken              Option[GitLabToken]
 	GiteaToken               Option[GiteaToken]
@@ -104,6 +105,7 @@ func DefaultNormalConfig() NormalConfigData {
 		DevRemote:                gitdomain.RemoteOrigin,
 		FeatureRegex:             None[FeatureRegex](),
 		ForgeType:                None[forgedomain.ForgeType](),
+		GitHubConnectorType:      None[forgedomain.GitHubConnectorType](),
 		GitHubToken:              None[GitHubToken](),
 		GitLabToken:              None[GitLabToken](),
 		GiteaToken:               None[GiteaToken](),
diff --git a/internal/config/configdomain/partial_config.go b/internal/config/configdomain/partial_config.go
index 3bbbc2738..cf21d64f1 100644
--- a/internal/config/configdomain/partial_config.go
+++ b/internal/config/configdomain/partial_config.go
@@ -19,6 +19,7 @@ type PartialConfig struct {
 	DevRemote                Option[gitdomain.Remote]
 	FeatureRegex             Option[FeatureRegex]
 	ForgeType                Option[forgedomain.ForgeType]
+	GitHubConnectorType      Option[forgedomain.GitHubConnectorType]
 	GitHubToken              Option[GitHubToken]
 	GitLabToken              Option[GitLabToken]
 	GitUserEmail             Option[GitUserEmail]
@@ -62,6 +63,8 @@ func NewPartialConfigFromSnapshot(snapshot SingleSnapshot, updateOutdated bool,
 	ec.Check(err)
 	forgeType, err := forgedomain.ParseForgeType(snapshot[KeyForgeType])
 	ec.Check(err)
+	githubConnectorType, err := forgedomain.ParseGitHubConnectorType(snapshot[KeyGitHubConnectorType])
+	ec.Check(err)
 	lineage, err := NewLineageFromSnapshot(snapshot, updateOutdated, removeLocalConfigValue)
 	ec.Check(err)
 	newBranchType, err := ParseBranchType(snapshot[KeyNewBranchType])
@@ -102,6 +105,7 @@ func NewPartialConfigFromSnapshot(snapshot SingleSnapshot, updateOutdated bool,
 		DevRemote:                gitdomain.NewRemote(snapshot[KeyDevRemote]),
 		FeatureRegex:             featureRegex,
 		ForgeType:                forgeType,
+		GitHubConnectorType:      githubConnectorType,
 		GitHubToken:              ParseGitHubToken(snapshot[KeyGithubToken]),
 		GitLabToken:              ParseGitLabToken(snapshot[KeyGitlabToken]),
 		GitUserEmail:             ParseGitUserEmail(snapshot[KeyGitUserEmail]),
@@ -143,6 +147,7 @@ func (self PartialConfig) Merge(other PartialConfig) PartialConfig {
 		DevRemote:                other.DevRemote.Or(self.DevRemote),
 		FeatureRegex:             other.FeatureRegex.Or(self.FeatureRegex),
 		ForgeType:                other.ForgeType.Or(self.ForgeType),
+		GitHubConnectorType:      other.GitHubConnectorType.Or(self.GitHubConnectorType),
 		GitHubToken:              other.GitHubToken.Or(self.GitHubToken),
 		GitLabToken:              other.GitLabToken.Or(self.GitLabToken),
 		GitUserEmail:             other.GitUserEmail.Or(self.GitUserEmail),
@@ -181,6 +186,7 @@ func (self PartialConfig) ToNormalConfig(defaults NormalConfigData) NormalConfig
 		DevRemote:                self.DevRemote.GetOrElse(defaults.DevRemote),
 		FeatureRegex:             self.FeatureRegex,
 		ForgeType:                self.ForgeType,
+		GitHubConnectorType:      self.GitHubConnectorType,
 		GitHubToken:              self.GitHubToken,
 		GitLabToken:              self.GitLabToken,
 		GiteaToken:               self.GiteaToken,
diff --git a/internal/config/configfile/data.go b/internal/config/configfile/data.go
index b345078d2..e8311fe48 100644
--- a/internal/config/configfile/data.go
+++ b/internal/config/configfile/data.go
@@ -39,10 +39,11 @@ type Create struct {
 }
 
 type Hosting struct {
-	DevRemote      *string `toml:"dev-remote"`
-	ForgeType      *string `toml:"forge-type"`
-	OriginHostname *string `toml:"origin-hostname"`
-	Platform       *string `toml:"platform"`
+	DevRemote           *string `toml:"dev-remote"`
+	ForgeType           *string `toml:"forge-type"`
+	GitHubConnectorType *string `toml:"github-connector"`
+	OriginHostname      *string `toml:"origin-hostname"`
+	Platform            *string `toml:"platform"`
 }
 
 func (self Hosting) IsEmpty() bool {
diff --git a/internal/config/configfile/load.go b/internal/config/configfile/load.go
index 9b701c59b..117c28e83 100644
--- a/internal/config/configfile/load.go
+++ b/internal/config/configfile/load.go
@@ -49,6 +49,7 @@ func Validate(data Data, finalMessages stringslice.Collector) (configdomain.Part
 	var devRemote Option[gitdomain.Remote]
 	var featureRegex Option[configdomain.FeatureRegex]
 	var forgeType Option[forgedomain.ForgeType]
+	var githubConnectorType Option[forgedomain.GitHubConnectorType]
 	var hostingOriginHostname Option[configdomain.HostingOriginHostname]
 	var mainBranch Option[gitdomain.LocalBranchName]
 	var newBranchType Option[configdomain.BranchType]
@@ -174,6 +175,12 @@ func Validate(data Data, finalMessages stringslice.Collector) (configdomain.Part
 				return configdomain.EmptyPartialConfig(), err
 			}
 		}
+		if data.Hosting.GitHubConnectorType != nil {
+			githubConnectorType, err = forgedomain.ParseGitHubConnectorType(*data.Hosting.GitHubConnectorType)
+			if err != nil {
+				return configdomain.EmptyPartialConfig(), err
+			}
+		}
 		if data.Hosting.OriginHostname != nil {
 			hostingOriginHostname = configdomain.ParseHostingOriginHostname(*data.Hosting.OriginHostname)
 		}
@@ -246,6 +253,7 @@ func Validate(data Data, finalMessages stringslice.Collector) (configdomain.Part
 		DevRemote:                devRemote,
 		FeatureRegex:             featureRegex,
 		ForgeType:                forgeType,
+		GitHubConnectorType:      githubConnectorType,
 		GitHubToken:              None[configdomain.GitHubToken](),
 		GitLabToken:              None[configdomain.GitLabToken](),
 		GitUserEmail:             None[configdomain.GitUserEmail](),
diff --git a/internal/forge/forgedomain/github_connector_type.go b/internal/forge/forgedomain/github_connector_type.go
new file mode 100644
index 000000000..a245d5854
--- /dev/null
+++ b/internal/forge/forgedomain/github_connector_type.go
@@ -0,0 +1,40 @@
+package forgedomain
+
+import (
+	"fmt"
+
+	"github.com/git-town/git-town/v21/internal/messages"
+	. "github.com/git-town/git-town/v21/pkg/prelude"
+)
+
+// GitHubConnectorType describes the various ways in which Git Town can connect to the GitHub API.
+type GitHubConnectorType string
+
+const (
+	GitHubConnectorTypeAPI GitHubConnectorType = "api" // connect to the GitHub API using Git Town's built-in API connector
+	GitHubConnectorTypeGh  GitHubConnectorType = "gh"  // connect to the GitHub API by calling GitHub's "gh" tool
+)
+
+func (self GitHubConnectorType) String() string {
+	return string(self)
+}
+
+// GitHubConnectorTypes provides all possible types that the GitHubConnectorTypes enum can have.
+func GitHubConnectorTypes() []GitHubConnectorType {
+	return []GitHubConnectorType{
+		GitHubConnectorTypeAPI,
+		GitHubConnectorTypeGh,
+	}
+}
+
+func ParseGitHubConnectorType(text string) (Option[GitHubConnectorType], error) {
+	if text == "" {
+		return None[GitHubConnectorType](), nil
+	}
+	for _, connectorType := range GitHubConnectorTypes() {
+		if connectorType.String() == text {
+			return Some(connectorType), nil
+		}
+	}
+	return None[GitHubConnectorType](), fmt.Errorf(messages.GitHubConnectorTypeUnknown, text)
+}
diff --git a/internal/forge/gh/connector.go b/internal/forge/gh/connector.go
new file mode 100644
index 000000000..a7dd3e84d
--- /dev/null
+++ b/internal/forge/gh/connector.go
@@ -0,0 +1,191 @@
+package gh
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"regexp"
+	"slices"
+	"strconv"
+	"strings"
+
+	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
+	"github.com/git-town/git-town/v21/internal/forge/github"
+	"github.com/git-town/git-town/v21/internal/git/gitdomain"
+	"github.com/git-town/git-town/v21/internal/gohacks/stringslice"
+	"github.com/git-town/git-town/v21/internal/messages"
+	"github.com/git-town/git-town/v21/internal/subshell/subshelldomain"
+	. "github.com/git-town/git-town/v21/pkg/prelude"
+)
+
+// Connector provides standardized connectivity for the given repository (github.com/owner/repo)
+// via the GitHub API.
+type Connector struct {
+	Backend  subshelldomain.Querier
+	Frontend subshelldomain.Runner
+}
+
+func (self Connector) CreateProposal(data forgedomain.CreateProposalArgs) error {
+	args := []string{"pr", "create", "--base=" + data.ParentBranch.String(), "--head=" + data.Branch.String()}
+	if title, hasTitle := data.ProposalTitle.Get(); hasTitle {
+		args = append(args, "--title="+title.String())
+	}
+	if body, hasBody := data.ProposalBody.Get(); hasBody {
+		args = append(args, "--body="+body.String())
+	}
+	err := self.Frontend.Run("gh", args...)
+	if err != nil {
+		return err
+	}
+	return self.Frontend.Run("gh", "pr", "view", "--web")
+}
+
+func (self Connector) DefaultProposalMessage(data forgedomain.ProposalData) string {
+	return github.DefaultProposalMessage(data)
+}
+
+func (self Connector) FindProposalFn() Option[func(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
+	return Some(self.findProposal)
+}
+
+func (self Connector) OpenRepository(runner subshelldomain.Runner) error {
+	return runner.Run("gh", "browse")
+}
+
+func (self Connector) SearchProposalFn() Option[func(gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
+	return Some(self.searchProposal)
+}
+
+func (self Connector) SquashMergeProposalFn() Option[func(int, gitdomain.CommitMessage) (err error)] {
+	return Some(self.squashMergeProposal)
+}
+
+func (self Connector) UpdateProposalSourceFn() Option[func(forgedomain.ProposalInterface, gitdomain.LocalBranchName, stringslice.Collector) error] {
+	return None[func(forgedomain.ProposalInterface, gitdomain.LocalBranchName, stringslice.Collector) error]()
+}
+
+func (self Connector) UpdateProposalTargetFn() Option[func(forgedomain.ProposalInterface, gitdomain.LocalBranchName, stringslice.Collector) error] {
+	return Some(self.updateProposalTarget)
+}
+
+func (self Connector) VerifyConnection() forgedomain.VerifyConnectionResult {
+	output, err := self.Backend.Query("gh", "auth", "status", "--active")
+	if err != nil {
+		return forgedomain.VerifyConnectionResult{
+			AuthenticatedUser:   None[string](),
+			AuthenticationError: err,
+			AuthorizationError:  nil,
+		}
+	}
+	return ParsePermissionsOutput(output)
+}
+
+func (self Connector) findProposal(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
+	out, err := self.Backend.Query("gh", "pr", "list", "--head="+branch.String(), "--base="+target.String(), "--json=number,title,body,mergeable,headRefName,baseRefName,url")
+	if err != nil {
+		return None[forgedomain.Proposal](), err
+	}
+	var parsed []ghData
+	err = json.Unmarshal([]byte(out), &parsed)
+	if err != nil || len(parsed) == 0 {
+		return None[forgedomain.Proposal](), err
+	}
+	if len(parsed) > 1 {
+		return None[forgedomain.Proposal](), fmt.Errorf(messages.ProposalMultipleFromToFound, len(parsed), branch, target)
+	}
+	pr := parsed[0]
+	proposal := forgedomain.Proposal{
+		Data: forgedomain.ProposalData{
+			Body:         NewOption(pr.Body),
+			MergeWithAPI: pr.Mergeable == "MERGEABLE",
+			Number:       pr.Number,
+			Source:       gitdomain.NewLocalBranchName(pr.HeadRefName),
+			Target:       gitdomain.NewLocalBranchName(pr.BaseRefName),
+			Title:        pr.Title,
+			URL:          pr.URL,
+		},
+		ForgeType: forgedomain.ForgeTypeGitHub,
+	}
+	return Some(proposal), nil
+}
+
+type ghData struct {
+	BaseRefName string `json:"baseRefName"`
+	Body        string `json:"body"`
+	HeadRefName string `json:"headRefName"`
+	Mergeable   string `json:"mergeable"`
+	Number      int    `json:"number"`
+	Title       string `json:"title"`
+	URL         string `json:"url"`
+}
+
+func (self Connector) searchProposal(branch gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
+	out, err := self.Backend.Query("gh", "--head="+branch.String(), "--json=number,title,body,mergeable,headRefName,baseRefName,url")
+	if err != nil {
+		return None[forgedomain.Proposal](), err
+	}
+	var parsed []ghData
+	err = json.Unmarshal([]byte(out), &parsed)
+	if err != nil || len(parsed) == 0 {
+		return None[forgedomain.Proposal](), err
+	}
+	if len(parsed) > 1 {
+		return None[forgedomain.Proposal](), fmt.Errorf("found more than one pull request: %d", len(parsed))
+	}
+	pr := parsed[0]
+	proposal := forgedomain.Proposal{
+		Data: forgedomain.ProposalData{
+			Body:         NewOption(pr.Body),
+			MergeWithAPI: pr.Mergeable == "MERGEABLE",
+			Number:       pr.Number,
+			Source:       gitdomain.NewLocalBranchName(pr.HeadRefName),
+			Target:       gitdomain.NewLocalBranchName(pr.BaseRefName),
+			Title:        pr.Title,
+			URL:          pr.URL,
+		},
+		ForgeType: forgedomain.ForgeTypeGitHub,
+	}
+	return Some(proposal), nil
+}
+
+func (self Connector) squashMergeProposal(number int, message gitdomain.CommitMessage) (err error) {
+	return self.Frontend.Run("gh", "pr", "merge", "--squash", "--body="+message.String(), strconv.Itoa(number))
+}
+
+func (self Connector) updateProposalTarget(proposalData forgedomain.ProposalInterface, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
+	return self.Frontend.Run("gh", "edit", strconv.Itoa(proposalData.Data().Number), "--base="+target.String())
+}
+
+func ParsePermissionsOutput(output string) forgedomain.VerifyConnectionResult {
+	result := forgedomain.VerifyConnectionResult{
+		AuthenticatedUser:   None[string](),
+		AuthenticationError: nil,
+		AuthorizationError:  nil,
+	}
+	lines := strings.Split(output, "\n")
+	regex := regexp.MustCompile(`Logged in to github.com account (\w+)`)
+	found := false
+	for _, line := range lines {
+		matches := regex.FindStringSubmatch(line)
+		if matches != nil {
+			result.AuthenticatedUser = NewOption(matches[1])
+			found = true
+			break
+		}
+	}
+	if !found {
+		result.AuthenticationError = errors.New(messages.AuthenticationMissing)
+	}
+	regex = regexp.MustCompile(`Token scopes: (.+)`)
+	for _, line := range lines {
+		matches := regex.FindStringSubmatch(line)
+		if matches != nil {
+			parts := strings.Split(matches[1], ", ")
+			if slices.Contains(parts, "'repo'") {
+				break
+			}
+			result.AuthorizationError = fmt.Errorf(messages.AuthorizationMissing, parts)
+		}
+	}
+	return result
+}
diff --git a/internal/forge/gh/doc.go b/internal/forge/gh/doc.go
new file mode 100644
index 000000000..f0783e4c7
--- /dev/null
+++ b/internal/forge/gh/doc.go
@@ -0,0 +1,2 @@
+// Package gh implements a forge connector that uses the "gh" tool from GitHub for API access.
+package gh
diff --git a/internal/forge/github/connector.go b/internal/forge/github/connector.go
index 43614e92f..99ba037ea 100644
--- a/internal/forge/github/connector.go
+++ b/internal/forge/github/connector.go
@@ -37,7 +37,7 @@ func (self Connector) CreateProposal(data forgedomain.CreateProposalArgs) error
 }
 
 func (self Connector) DefaultProposalMessage(data forgedomain.ProposalData) string {
-	return forgedomain.CommitBody(data, fmt.Sprintf("%s (#%d)", data.Title, data.Number))
+	return DefaultProposalMessage(data)
 }
 
 func (self Connector) FindProposalFn() Option[func(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
@@ -71,7 +71,7 @@ func (self Connector) OpenRepository(runner subshelldomain.Runner) error {
 }
 
 func (self Connector) RepositoryURL() string {
-	return fmt.Sprintf("https://%s/%s/%s", self.HostnameWithStandardPort(), self.Organization, self.Repository)
+	return RepositoryURL(self.HostnameWithStandardPort(), self.Organization, self.Repository)
 }
 
 func (self Connector) SearchProposalFn() Option[func(gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
@@ -219,6 +219,10 @@ func (self Connector) updateProposalTarget(proposalData forgedomain.ProposalInte
 	return nil
 }
 
+func DefaultProposalMessage(data forgedomain.ProposalData) string {
+	return forgedomain.CommitBody(data, fmt.Sprintf("%s (#%d)", data.Title, data.Number))
+}
+
 // NewConnector provides a fully configured GithubConnector instance
 // if the current repo is hosted on GitHub, otherwise nil.
 func NewConnector(args NewConnectorArgs) (Connector, error) {
@@ -251,6 +255,10 @@ type NewConnectorArgs struct {
 	RemoteURL giturl.Parts
 }
 
+func RepositoryURL(hostNameWithStandardPort string, organization string, repository string) string {
+	return fmt.Sprintf("https://%s/%s/%s", hostNameWithStandardPort, organization, repository)
+}
+
 // parsePullRequest extracts standardized proposal data from the given GitHub pull-request.
 func parsePullRequest(pullRequest *github.PullRequest) forgedomain.ProposalData {
 	return forgedomain.ProposalData{
diff --git a/internal/forge/new_connector.go b/internal/forge/new_connector.go
index b47ea1415..f592c78f6 100644
--- a/internal/forge/new_connector.go
+++ b/internal/forge/new_connector.go
@@ -7,15 +7,18 @@ import (
 	"github.com/git-town/git-town/v21/internal/forge/bitbucketdatacenter"
 	"github.com/git-town/git-town/v21/internal/forge/codeberg"
 	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
+	"github.com/git-town/git-town/v21/internal/forge/gh"
 	"github.com/git-town/git-town/v21/internal/forge/gitea"
 	"github.com/git-town/git-town/v21/internal/forge/github"
 	"github.com/git-town/git-town/v21/internal/forge/gitlab"
 	"github.com/git-town/git-town/v21/internal/git/gitdomain"
+	"github.com/git-town/git-town/v21/internal/subshell/subshelldomain"
 	. "github.com/git-town/git-town/v21/pkg/prelude"
 )
 
 // NewConnector provides an instance of the forge connector to use based on the given gitConfig.
-func NewConnector(config config.UnvalidatedConfig, remote gitdomain.Remote, log print.Logger) (Option[forgedomain.Connector], error) {
+// TODO: replace most arguments with OpenRepoResult
+func NewConnector(config config.UnvalidatedConfig, remote gitdomain.Remote, log print.Logger, frontend subshelldomain.Runner, backend subshelldomain.Querier) (Option[forgedomain.Connector], error) {
 	remoteURL, hasRemoteURL := config.NormalConfig.RemoteURL(remote).Get()
 	forgeType := config.NormalConfig.ForgeType
 	platform, hasPlatform := Detect(remoteURL, forgeType).Get()
@@ -23,6 +26,7 @@ func NewConnector(config config.UnvalidatedConfig, remote gitdomain.Remote, log
 		return None[forgedomain.Connector](), nil
 	}
 	var connector forgedomain.Connector
+	var err error
 	switch platform {
 	case forgedomain.ForgeTypeBitbucket:
 		connector = bitbucketcloud.NewConnector(bitbucketcloud.NewConnectorArgs{
@@ -43,7 +47,6 @@ func NewConnector(config config.UnvalidatedConfig, remote gitdomain.Remote, log
 		})
 		return Some(connector), nil
 	case forgedomain.ForgeTypeCodeberg:
-		var err error
 		connector, err = codeberg.NewConnector(codeberg.NewConnectorArgs{
 			APIToken:  config.NormalConfig.CodebergToken,
 			Log:       log,
@@ -58,7 +61,24 @@ func NewConnector(config config.UnvalidatedConfig, remote gitdomain.Remote, log
 		})
 		return Some(connector), nil
 	case forgedomain.ForgeTypeGitHub:
-		var err error
+		if githubConnectorType, hasGitHubConnectorType := config.NormalConfig.GitHubConnectorType.Get(); hasGitHubConnectorType {
+			switch githubConnectorType {
+			case forgedomain.GitHubConnectorTypeAPI:
+				connector, err = github.NewConnector(github.NewConnectorArgs{
+					APIToken:  config.NormalConfig.GitHubToken,
+					Log:       log,
+					RemoteURL: remoteURL,
+				})
+				return Some(connector), err
+			case forgedomain.GitHubConnectorTypeGh:
+				connector = gh.Connector{
+					Backend:  backend,
+					Frontend: frontend,
+				}
+				return Some(connector), err
+			}
+		}
+		// no GitHubConnectorType specified --> use the API connector
 		connector, err = github.NewConnector(github.NewConnectorArgs{
 			APIToken:  config.NormalConfig.GitHubToken,
 			Log:       log,
diff --git a/internal/git/commands.go b/internal/git/commands.go
index 6b5f59972..c07a2e236 100644
--- a/internal/git/commands.go
+++ b/internal/git/commands.go
@@ -783,6 +783,10 @@ func (self *Commands) RemoveGitAlias(runner subshelldomain.Runner, aliasableComm
 	return runner.Run("git", "config", "--global", "--unset", aliasableCommand.Key().String())
 }
 
+func (self *Commands) RemoveGitHubConnectorType(runner subshelldomain.Runner) error {
+	return runner.Run("git", "config", "--unset", configdomain.KeyGitHubConnectorType.String())
+}
+
 func (self *Commands) RemoveGitHubToken(runner subshelldomain.Runner) error {
 	return runner.Run("git", "config", "--unset", configdomain.KeyGithubToken.String())
 }
@@ -882,6 +886,10 @@ func (self *Commands) SetGitAlias(runner subshelldomain.Runner, aliasableCommand
 	return runner.Run("git", "config", "--global", aliasableCommand.Key().String(), "town "+aliasableCommand.String())
 }
 
+func (self *Commands) SetGitHubConnectorType(runner subshelldomain.Runner, value forgedomain.GitHubConnectorType) error {
+	return runner.Run("git", "config", configdomain.KeyGitHubConnectorType.String(), value.String())
+}
+
 func (self *Commands) SetGitHubToken(runner subshelldomain.Runner, value configdomain.GitHubToken, scope configdomain.ConfigScope) error {
 	return runner.Run("git", "config", scope.GitFlag(), configdomain.KeyGithubToken.String(), value.String())
 }
diff --git a/internal/messages/en.go b/internal/messages/en.go
index 306a4493e..508ae97cf 100644
--- a/internal/messages/en.go
+++ b/internal/messages/en.go
@@ -10,6 +10,8 @@ const (
 	APIUnexpectedResultDataStructure   = "unexpected result data structure"
 	APIUpdateProposalSource            = "Updating source branch of proposal %s to %s ... "
 	APIUpdateProposalTarget            = "Updating target branch of proposal %s to %s ... "
+	AuthenticationMissing              = "not logged in"
+	AuthorizationMissing               = `cannot find "repo" scope: %v`
 	BitbucketAppPassword               = "Bitbucket app password: %s"
 	BitbucketUsername                  = "Bitbucket username: %s"
 	BranchAlreadyExistsLocally         = "there is already a branch %q"
@@ -120,6 +122,8 @@ Please upgrade to the new format: create.new-branch-type = "prototype"`
 	GiteaToken                          = "Gitea token: %s\n"
 	GitAnotherProcessIsRunningRetry     = "another git process seems to be running in this repository, retrying in 1 sec ..."
 	GitHubEnterpriseInitializeError     = "cannot initialize GitHub Enterprise client: %s"
+	GitHubConnectorType                 = "GitHub connector type: %s\n"
+	GitHubConnectorTypeUnknown          = "unknown GitHubConnectorType: %q"
 	GitHubToken                         = "GitHub token: %s\n"
 	GitLabToken                         = "GitLab token: %s\n"
 	GitDirMissing                       = "cannot determine the '.git' directory: %w"
diff --git a/website/src/SUMMARY.md b/website/src/SUMMARY.md
index 3a15a422c..97db0431b 100644
--- a/website/src/SUMMARY.md
+++ b/website/src/SUMMARY.md
@@ -74,6 +74,7 @@
     - [Bitbucket username](preferences/bitbucket-username.md)
     - [Codeberg token](preferences/codeberg-token.md)
     - [Gitea token](preferences/gitea-token.md)
+    - [GitHub connector](preferences/github-connector.md)
     - [GitHub token](preferences/github-token.md)
     - [GitLab token](preferences/gitlab-token.md)
   - [Ship]()
diff --git a/website/src/configuration.md b/website/src/configuration.md
index ab5f813df..2b87ef077 100644
--- a/website/src/configuration.md
+++ b/website/src/configuration.md
@@ -1,9 +1,10 @@
 # Set up configuration
 
 If your repository already contains a `.git-branches.toml` or `.git-town.toml`
-file, you are good to go. If not or something doesn't work, run Git Town's setup
-assistant. It walks you through every configuration option and gives you a
-chance to adjust it.
+file, you are good to go. If not, or something doesn't work, you can run Git
+Town's setup assistant to configure Git Town. It walks you through every
+available configuration option, explains it, and gives you a chance to adjust
+it.
 
 ```
 git town config setup
@@ -14,17 +15,20 @@ You can find more background around how Git Town stores configuration in the
 
 ### Access tokens
 
-API access multiplies Git Town's utility:
+Some of Git Towns' functionality requires access the API of your forge:
 
 - if the parent of a branch is not known, Git Town can look for a pull requests
   of this branch and uses their parent branch
-- updates affected pull requests when you prepend, rename, remove branches or
-  change their parent
+- when you prepend, rename, remove branches or change their parent, Git Town can
+  updates the affected pull requests
 - click the "merge" button on a pull request from your CLI
 
-Configuring API access takes only one minute. Here is how you do it:
+Configuring API access is easy. Here is how you do it:
 
-- GitHub: [access token](preferences/github-token.md)
+- GitHub: You can set up the [access token](preferences/github-token.md) that
+  allows Git Town to talk to GitHub's API, or set up GitHub's
+  [gh tool](https://cli.github.com) and let Git Town interact with Github
+  through it
 - GitLab: [access token](preferences/gitlab-token.md)
 - Bitbucket: [username](preferences/bitbucket-username.md) and
   [app password](preferences/bitbucket-app-password.md)
diff --git a/website/src/preferences/github-connector.md b/website/src/preferences/github-connector.md
new file mode 100644
index 000000000..0d5c38355
--- /dev/null
+++ b/website/src/preferences/github-connector.md
@@ -0,0 +1,37 @@
+# GitHub connector
+
+Git Town can interact with GitHub in two different ways.
+
+1. **GitHub API:** <br> Git Town communicates directly with the GitHub API using
+   a personal access token. You'll need to generate this token
+   [github.com/settings/tokens](https://github.com/settings/tokens).
+
+   By default, Git stores such tokens in plaintext in your Git configuration. To
+   avoid this, consider configuring Git to use your operating system's encrypted
+   [credentials storage](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage)
+   for better security.
+
+2. **GitHub CLI (gh):** <br> The [gh](https://cli.github.com) CLI handles
+   authentication and token management for you.
+
+## config file
+
+It is generally not recommended to hardcode the connector type in your config
+file, as it enforces usage or non-usage of `gh` to your entire team. If you want
+to set it explicitly, it would look like this:
+
+```toml
+[hosting]
+github-connector = "api" # or "gh"
+```
+
+## Git metadata
+
+You can configure the API token manually by running:
+
+```wrap
+git config [--global] git-town.github-connector <api|gh>
+```
+
+The optional `--global` flag applies this setting to all Git repositories on
+your machine. Without it, the setting applies only to the current repository.



Test patch:
diff --git a/internal/forge/forgedomain/github_connector_type_test.go b/internal/forge/forgedomain/github_connector_type_test.go
new file mode 100644
index 000000000..d5599124b
--- /dev/null
+++ b/internal/forge/forgedomain/github_connector_type_test.go
@@ -0,0 +1,40 @@
+package forgedomain_test
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
+	. "github.com/git-town/git-town/v21/pkg/prelude"
+	"github.com/shoenig/test/must"
+)
+
+func TestParseGitHubConnectorType(t *testing.T) {
+	t.Parallel()
+	tests := map[string]struct {
+		val Option[forgedomain.GitHubConnectorType]
+		err error
+	}{
+		"api": {
+			val: Some(forgedomain.GitHubConnectorTypeAPI),
+			err: nil,
+		},
+		"gh": {
+			val: Some(forgedomain.GitHubConnectorTypeGh),
+			err: nil,
+		},
+		"invalid": {
+			val: None[forgedomain.GitHubConnectorType](),
+			err: errors.New(`unknown GitHubConnectorType: "invalid"`),
+		},
+		"": {
+			val: None[forgedomain.GitHubConnectorType](),
+			err: nil,
+		},
+	}
+	for give, want := range tests {
+		have, err := forgedomain.ParseGitHubConnectorType(give)
+		must.Eq(t, want.err, err)
+		must.Eq(t, want.val, have)
+	}
+}
diff --git a/internal/forge/gh/connector_test.go b/internal/forge/gh/connector_test.go
new file mode 100644
index 000000000..b72e4008c
--- /dev/null
+++ b/internal/forge/gh/connector_test.go
@@ -0,0 +1,63 @@
+package gh_test
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
+	"github.com/git-town/git-town/v21/internal/forge/gh"
+	. "github.com/git-town/git-town/v21/pkg/prelude"
+	"github.com/shoenig/test/must"
+)
+
+func TestParseStatusOutput(t *testing.T) {
+	t.Parallel()
+
+	t.Run("logged into github.com with correct scopes", func(t *testing.T) {
+		t.Parallel()
+		give := `
+github.com
+  ✓ Logged in to github.com account kevgo (keyring)
+  - Active account: true
+  - Git operations protocol: ssh
+  - Token: gho_************************************
+  - Token scopes: 'gist', 'read:org', 'repo'`[1:]
+		have := gh.ParsePermissionsOutput(give)
+		want := forgedomain.VerifyConnectionResult{
+			AuthenticatedUser:   Some("kevgo"),
+			AuthenticationError: nil,
+			AuthorizationError:  nil,
+		}
+		must.Eq(t, want, have)
+	})
+
+	t.Run("logged into github.com with missing scopes", func(t *testing.T) {
+		t.Parallel()
+		give := `
+github.com
+  ✓ Logged in to github.com account kevgo (keyring)
+  - Active account: true
+  - Git operations protocol: ssh
+  - Token: gho_************************************
+  - Token scopes: 'gist', 'read:org'`[1:]
+		have := gh.ParsePermissionsOutput(give)
+		want := forgedomain.VerifyConnectionResult{
+			AuthenticatedUser:   Some("kevgo"),
+			AuthenticationError: nil,
+			AuthorizationError:  errors.New(`cannot find "repo" scope: ['gist' 'read:org']`),
+		}
+		must.Eq(t, want, have)
+	})
+
+	t.Run("not logged in", func(t *testing.T) {
+		t.Parallel()
+		give := "You are not logged into any GitHub hosts. To log in, run: gh auth login"
+		have := gh.ParsePermissionsOutput(give)
+		want := forgedomain.VerifyConnectionResult{
+			AuthenticatedUser:   None[string](),
+			AuthenticationError: errors.New("not logged in"),
+			AuthorizationError:  nil,
+		}
+		must.Eq(t, want, have)
+	})
+}