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:
- A Factory pattern that constructs all shared dependencies and injects them into every command.
- A first-class extension system that installs third-party binaries as
gh-*executables and registers them as Cobra subcommands at startup. - 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 thecmdutil.Factorystruct. 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.Factorystruct (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 discoveredgh-*extension executables as Cobra commands, expands user-defined aliases, installs the auth checkPersistentPreRunEgate, 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/apiwith gh-specific defaults:X-GitHub-Api-Versionheader, SSO header extraction, scope suggestion error messages, and caching viaNewCachedHTTPClient. REST calls are plain*http.Clientdriven; GraphQL calls useshurcooL/githubv4-style struct-based queries viago-gh. - Key types:
Client,HTTPError,GraphQLError,HTTPClientOptions - Dependencies:
cli/go-gh/v2/pkg/api, stdlibnet/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 ininternal/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 (implementsgh.Config). Manages multi-account state with config migration support.authflowimplements 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 asf.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’sDispatch()method runs the matching executable with the remaining CLI args, passingGH_TOKENand 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
ghcommand (e.g.pr,issue,repo,release). Commands receive thecmdutil.Factoryand use it to lazily acquire what they need. Most commands use a privateRunOptionsstruct populated from flags, then passed to arun(opts)function — allowing unit tests to bypass Cobra entirely. - Key types: Per-command
Options/RunOptionsstructs;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():
- Build metadata —
build.Version,build.Dateinjected via ldflags at compile time. - Agent detection —
agents.Detect()inspects env vars to identify if running inside GitHub Actions, Copilot, etc.; result injected asUser-Agentcomponent. - Factory construction —
factory.New(version, agent)wires all dependencies as lazy closures. Config is cached once loaded; HTTP clients are recreated per call (cheap, stateless). - Config migration —
cfg.Migrate(migration.MultiAccount{})upgrades single-account config files to multi-account format before any command uses config. - Update check — goroutine started with its own cancellable context; writes to
updateMessageChan; result is drained after cobra returns (not before, to avoid blocking). - Color setup — survey color overrides applied based on
IOStreams.ColorEnabled(). - Command tree —
root.NewCmdRoot()synchronously registers all 35+ core commands, discovered extensions, and aliases. - Cobra dispatch —
ExecuteContextC(ctx)routes to handler;PersistentPreRunEchecks auth for commands that require it. - Exit code mapping — Typed error inspection maps
cmdutil.SilentError,PendingError,UserCancellation,AuthError,ExternalCommandExitErrorto 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:
| Source | Mechanism | Priority |
|---|---|---|
| Environment variables | GH_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 config | Per-hostname overrides in hosts.yml | Medium |
| Compiled defaults | ldflags-injected OAuth client IDs, updater repo | Lowest |
- 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/configlibrary built specifically for gh-style multi-host YAML config.
Key design decisions#
Factory as explicit DI container: Every command receives
*cmdutil.Factoryas a constructor argument. This makes test setup trivial — swapFactory.HttpClientfor a mock returninghttpmock.Registryresponses. No global state. The lazy-closure pattern means unused services (e.g.,GitClientingh auth login) have zero startup cost.Commands in
pkg/, notinternal/: 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.Two base-repo resolvers:
BaseRepoFunc(fast: picks first git remote) vsSmartBaseRepoFunc(slower: calls GitHub API to resolve fork networks). Commands that need the canonical parent repo (pr, issue, release, repo) receiverepoResolvingCmdFactorywithSmartBaseRepoFunc; simpler commands use the default. This is wired inroot.NewCmdRoot— a rare case of the command registry making a per-command DI decision.Exit code taxonomy:
exitOK=0,exitError=1,exitCancel=2,exitAuth=4,exitPending=8. Shell scripts can distinguish auth failures from general errors, enablinggh-aware CI pipelines without parsing stderr.Extensions as first-class citizens:
gh-*executables are discovered at startup and registered as Cobra subcommands (in theextensiongroup). This means tab-completion, help integration, and the--helprouting all work transparently for extensions, treating them as peers of core commands rather than escape hatches.