GitHub CLI (gh) — Architecture#

Architectural style#

Layered CLI with Factory-based Dependency Injection and Plugin extensibility

gh is a monolithic CLI application structured as a deep Cobra command tree. It is not a microkernel in the classic sense, but it achieves a similar plug-in feel through:

  1. A Factory pattern that constructs all shared dependencies and injects them into every command.
  2. A first-class extension system that installs third-party binaries as gh-* executables and registers them as Cobra subcommands at startup.
  3. A layered package structure that enforces a strict dependency direction: entry point → bootstrap → command layer → API/internal services.

Evidence: pkg/cmd/factory/default.go shows a single New() function that assembles every shared service (IOStreams, HttpClient, GitClient, Config, ExtensionManager, Prompter, Browser) before any command runs. pkg/cmd/root/root.go registers all 35+ subcommands plus dynamically discovered extensions and user-defined aliases at startup.

Component diagram (textual)#

┌──────────────────────────────────────────────────────────────────┐
│ cmd/gh/main.go  (7-line stub)                                    │
│   └── internal/ghcmd.Main()  (bootstrap + cobra dispatch)        │
│         ├── factory.New()  ─────────────────────────────────┐   │
│         │     constructs cmdutil.Factory                     │   │
│         └── root.NewCmdRoot(factory)                         │   │
│               registers all pkg/cmd/* subcommands            │   │
│               registers extensions + aliases                 │   │
│               cobra.Execute()                                │   │
└─────────────────────────────────────┬────────────────────────┘   │
                                      │                             │
         ┌────────────────────────────▼──────────────┐             │
         │              cmdutil.Factory               │◄────────────┘
         │  Config, HttpClient, PlainHttpClient,      │
         │  GitClient, IOStreams, Prompter,            │
         │  Browser, ExtensionManager, BaseRepo,      │
         │  Remotes, Branch                           │
         └──────┬──────────────┬────────────┬─────────┘
                │              │            │
    ┌───────────▼───┐  ┌───────▼─────┐  ┌──▼──────────────┐
    │  api/Client   │  │ git.Client  │  │ extensions.     │
    │  REST+GraphQL │  │ subprocess  │  │ ExtensionManager│
    │  GitHub API   │  │ wrapper     │  │ gh-* binaries   │
    └───────────────┘  └─────────────┘  └─────────────────┘
                │
    ┌───────────▼──────────────────────────┐
    │  internal/config  internal/authflow  │
    │  internal/ghrepo  internal/ghinstance│
    │  internal/prompter  internal/update  │
    └──────────────────────────────────────┘

Core components#

Bootstrap / Main#

  • Package: internal/ghcmd
  • Responsibility: Application entry: reads build metadata, constructs the Factory, triggers config migration, launches a background update-check goroutine, builds the root Cobra command tree, executes it, and translates errors to structured exit codes (0 OK, 1 error, 2 cancel, 4 auth, 8 pending).
  • Key types: exitCode, Main() exitCode
  • Dependencies: factory, root, cmdutil, iostreams, update, config, agents

Factory#

  • Package: pkg/cmd/factory, pkg/cmdutil
  • Responsibility: Central dependency-injection hub. factory.New() assembles all shared services as lazy function closures stored on the cmdutil.Factory struct. Each service is a thunk (func() (*http.Client, error)) so construction is deferred until first use by a command. Config is cached after first load.
  • Key types: cmdutil.Factory struct (12 fields: concrete services + lazy function closures)
  • Dependencies: api, git, config, iostreams, browser, prompter, extensions, ghrepo, context

Root Command / Command Registry#

  • Package: pkg/cmd/root
  • Responsibility: Constructs the root cobra.Command, wires all 35+ feature subcommands, registers discovered gh-* extension executables as Cobra commands, expands user-defined aliases, installs the auth check PersistentPreRunE gate, and customizes help/usage formatting.
  • Key types: AuthError, ExternalCommandExitError
  • Dependencies: Every pkg/cmd/<feature> package, factory, cmdutil, extensions

API Client#

  • Package: api/
  • Responsibility: Dual REST+GraphQL client for the GitHub API. Wraps cli/go-gh/v2/pkg/api with gh-specific defaults: X-GitHub-Api-Version header, SSO header extraction, scope suggestion error messages, and caching via NewCachedHTTPClient. REST calls are plain *http.Client driven; GraphQL calls use shurcooL/githubv4-style struct-based queries via go-gh.
  • Key types: Client, HTTPError, GraphQLError, HTTPClientOptions
  • Dependencies: cli/go-gh/v2/pkg/api, stdlib net/http

Domain Interfaces (internal/gh)#

  • Package: internal/gh
  • Responsibility: Declares the canonical contracts for the application’s domain: Config, AuthConfig, AliasConfig, Migration. These interfaces decouple command code from implementation details of the config subsystem. Concrete implementations live in internal/config.
  • Key types: Config (17 methods), AuthConfig (13 methods), AliasConfig (4 methods), Migration
  • Dependencies: pkg/option, cli/go-gh/v2/pkg/config

Config & Auth#

  • Package: internal/config, internal/authflow
  • Responsibility: Reads/writes ~/.config/gh/ YAML files (implements gh.Config). Manages multi-account state with config migration support. authflow implements OAuth device and browser flows, injecting client credentials via build-time ldflags.
  • Key types: config.NewConfig(), migration.MultiAccount
  • Dependencies: cli/go-gh/v2/pkg/config, internal/keyring

IOStreams#

  • Package: pkg/iostreams
  • Responsibility: Abstracts stdin/stdout/stderr with TTY detection, color support, pager launching, spinner management, and prompt enable/disable. Provides Test() constructor returning in-memory streams for tests. Used everywhere as f.IOStreams.
  • Key types: IOStreams, ColorScheme
  • Dependencies: stdlib os, github.com/muesli/termenv

Extension Manager#

  • Package: pkg/cmd/extension, pkg/extensions
  • Responsibility: Discovers, installs, upgrades, and dispatches gh-* extension executables. Extensions are Git repos or pre-built binaries installed under ~/.local/share/gh/extensions/. The manager’s Dispatch() method runs the matching executable with the remaining CLI args, passing GH_TOKEN and other env vars.
  • Key types: extensions.ExtensionManager (interface), extension.Manager (concrete), extensions.Extension (interface)
  • Dependencies: git.Client, api, config

Feature Commands (pkg/cmd/*)#

  • Package: 35+ packages under pkg/cmd/
  • Responsibility: Each package implements one top-level gh command (e.g. pr, issue, repo, release). Commands receive the cmdutil.Factory and use it to lazily acquire what they need. Most commands use a private RunOptions struct populated from flags, then passed to a run(opts) function — allowing unit tests to bypass Cobra entirely.
  • Key types: Per-command Options / RunOptions structs; NewCmd*() constructor functions
  • Dependencies: cmdutil.Factory (received via DI), api.Client, iostreams, ghrepo

Data flow#

Typical command execution: gh pr list

1. user invokes: gh pr list --limit 10

2. cmd/gh/main.go → ghcmd.Main()
   a. factory.New() builds Factory with lazy closures
   b. root.NewCmdRoot(factory) registers all commands incl. prCmd
   c. SmartBaseRepoFunc installed on repoResolvingCmdFactory for pr cmds
   d. rootCmd.ExecuteContextC(ctx) → cobra routes to prCmd list handler

3. pkg/cmd/pr/list/list.go: NewCmdList()
   a. Cobra populates Options from flags
   b. RunE calls: httpClient, err := f.HttpClient()
      → factory.httpClientFunc() reads Config for auth token
      → api.NewHTTPClient() constructs http.Client with bearer auth
   c. baseRepo, err := f.BaseRepo()
      → SmartBaseRepoFunc → f.Remotes() → git remote -v
      → api.GraphQL to resolve repo network → returns ghrepo.Interface
   d. api.PullRequestList(httpClient, repo, filters)
      → HTTP GET /repos/{owner}/{repo}/pulls
      → or GraphQL query for richer data
   e. tableprinter or JSON serializer formats output
   f. f.IOStreams.Out receives rendered table; pager launched if TTY

4. ghcmd.Main() checks HasFailed(), drains updateMessageChan
   → prints update notification to stderr if newer version found
   → os.Exit(0)

Extension dispatch: gh my-ext do-thing

1. root.NewCmdRoot iterates em.List()
2. Each extension registered as cobra command with RunE = Dispatch()
3. Dispatch() exec.Cmd(extensionBinary, remainingArgs)
   with GH_TOKEN, GH_REPO, GH_HOST injected as env vars
4. ExternalCommandExitError passes exit code back to ghcmd.Main()

Initialization / Bootstrap#

Sequence in ghcmd.Main():

  1. Build metadatabuild.Version, build.Date injected via ldflags at compile time.
  2. Agent detectionagents.Detect() inspects env vars to identify if running inside GitHub Actions, Copilot, etc.; result injected as User-Agent component.
  3. Factory constructionfactory.New(version, agent) wires all dependencies as lazy closures. Config is cached once loaded; HTTP clients are recreated per call (cheap, stateless).
  4. Config migrationcfg.Migrate(migration.MultiAccount{}) upgrades single-account config files to multi-account format before any command uses config.
  5. Update check — goroutine started with its own cancellable context; writes to updateMessageChan; result is drained after cobra returns (not before, to avoid blocking).
  6. Color setup — survey color overrides applied based on IOStreams.ColorEnabled().
  7. Command treeroot.NewCmdRoot() synchronously registers all 35+ core commands, discovered extensions, and aliases.
  8. Cobra dispatchExecuteContextC(ctx) routes to handler; PersistentPreRunE checks auth for commands that require it.
  9. Exit code mapping — Typed error inspection maps cmdutil.SilentError, PendingError, UserCancellation, AuthError, ExternalCommandExitError to specific exit codes.

DI pattern: Manual wiring via constructor injection. cmdutil.Factory is a plain struct — no DI framework (no wire/dig/fx). Dependencies are function closures (lazy), allowing commands to pull only what they need without triggering unneeded auth or git operations.

Configuration#

gh uses a layered configuration system:

SourceMechanismPriority
Environment variablesGH_TOKEN, GH_HOST, GH_REPO, GH_PAGER, GH_PROMPT_DISABLED, etc.Highest
User config file~/.config/gh/config.yml (YAML, via cli/go-gh/v2/pkg/config)Medium
Host-scoped configPer-hostname overrides in hosts.ymlMedium
Compiled defaultsldflags-injected OAuth client IDs, updater repoLowest
  • Reading is lazy: f.Config() first call reads and caches config; subsequent calls reuse.
  • Auth tokens stored in system keyring when available (via internal/keyring), falling back to config file.
  • GHES feature detection (internal/featuredetection) gates newer API features by comparing server version against a minimum requirement.
  • No Viper — uses the team’s own cli/go-gh/v2/pkg/config library built specifically for gh-style multi-host YAML config.

Key design decisions#

  1. Factory as explicit DI container: Every command receives *cmdutil.Factory as a constructor argument. This makes test setup trivial — swap Factory.HttpClient for a mock returning httpmock.Registry responses. No global state. The lazy-closure pattern means unused services (e.g., GitClient in gh auth login) have zero startup cost.

  2. Commands in pkg/, not internal/: Unusual by Go convention. The rationale is that extension authors and the companion SDK (go-gh) need to reference command-level types and helpers. Accepting external importability is an explicit tradeoff for ecosystem reuse.

  3. Two base-repo resolvers: BaseRepoFunc (fast: picks first git remote) vs SmartBaseRepoFunc (slower: calls GitHub API to resolve fork networks). Commands that need the canonical parent repo (pr, issue, release, repo) receive repoResolvingCmdFactory with SmartBaseRepoFunc; simpler commands use the default. This is wired in root.NewCmdRoot — a rare case of the command registry making a per-command DI decision.

  4. Exit code taxonomy: exitOK=0, exitError=1, exitCancel=2, exitAuth=4, exitPending=8. Shell scripts can distinguish auth failures from general errors, enabling gh-aware CI pipelines without parsing stderr.

  5. Extensions as first-class citizens: gh-* executables are discovered at startup and registered as Cobra subcommands (in the extension group). This means tab-completion, help integration, and the --help routing all work transparently for extensions, treating them as peers of core commands rather than escape hatches.