Documentation
Building a CLI with rungrad
Start with rungrad new mytool (see Getting started). This
guide explains the generated code and the APIs used to turn the starter into
your own tool. Your command does the work. rungrad handles text and JSON output,
dry-run previews, confirmation before destructive actions, exit codes, help,
generated docs, the hidden manifest, and scoring.
rungrad handles output modes, terminal and pager controls, redaction, feature modules, catalog validation, docs/help checks, manifest output, config/auth/service hooks, browser-opening helpers, test helpers, and scoring. Your CLI owns API calls, command behavior, service URLs, login flow, workspace rules, and extra secret values unless it registers them with rungrad.
To port an existing Cobra CLI, follow the ordered path in Migrating from Cobra. This guide documents the APIs used there.
The App
A tool constructs an App, registers commands, and calls Run.
app := rungrad.New(rungrad.AppConfig{
Name: "mytool",
Short: "mytool CLI",
Long: "Longer description shown on the root --help.",
Version: "v0.1.0",
EnvVar: "MYTOOL_TOKEN", // credential environment variable
})
app.AddCommand(/* commands */)
os.Exit(app.Run(os.Args[1:], os.Stdout, os.Stderr))
In the default rungrad-owned surface mode (Surface.GlobalFlags unset or
SurfaceRungradOwned), New registers the five persistent global flags
(--json, --dry-run, --no-prompt, --quiet, --config), enables Cobra
--version when AppConfig.Version is set, leaves Cobra’s generated
completion command enabled, registers the hidden __rungrad_manifest
endpoint, and installs the validate-then-auth pre-run hook. Set
AppConfig{AdvancedOutput: true} to also register --plain, --jq,
--template, --include-meta, --no-color, --no-ansi, and --no-pager and
install the output-mode guard. Set AppConfig.Resolution to add profile,
auth-file, and service-endpoint globals. The reference CLI opts into advanced
output and resolution in
cmd/rgref/main.go.
Existing product CLIs customize those public framework surfaces through
AppConfig.Surface; see Framework surface customization.
Run executes the tool, prints any error in the active output mode, and returns
the classified process exit code. RunIO is the same with an explicit input
reader, which the test harness uses to drive prompts.
Commands
A Command is a thin builder over Cobra that records the metadata rungrad needs
for help, docs, and conformance:
&rungrad.Command{
Use: "list",
Short: "List widgets",
Examples: []string{"mytool widget list", "mytool widget list --json"},
Related: []string{"mytool widget create"},
OutputModes: []string{rungrad.OutputModeHuman, rungrad.OutputModeJSON},
Run: func(f *rungrad.Factory, cmd *cobra.Command, args []string) error {
return f.WriteResult(data, func(w io.Writer) { /* human view */ })
},
}
Fields:
ExamplesandRelatedpopulate the examples and related-commands sections of--help, which the spec requires for self-describing help.OutputModesdocuments the supported forms for docs and conformance.Mutates: truemarks a state-changing command that should honor--dry-run.Destructive: truemarks a destructive command. It impliesMutatesand is expected to gate the action behindf.ConfirmDestructive(see Dry run).RequiresAuth: truemakes the validate-then-auth pre-run hook load a credential before the command runs, failing with the auth exit code when none is available.Extensionsattaches product-owned command metadata to the manifest under namespaced keys such asexample.com/product.GroupIDsorts the command under a named help group registered withapp.AddGroup.Configure func(*cobra.Command)registers local flags.Argsis a Cobra positional-args validator.
Use cmd.AddCommand(sub...) to build a parent command with subcommands.
Feature modules and command catalog
A large CLI can split commands across compiled-in feature modules. A module is an
ordinary Go value registered explicitly at startup. It implements the
rungrad.FeatureModule interface:
type FeatureModule interface {
Groups() []Group
Commands() []*Command
Catalog() []CommandSpec
}
A module looks like this:
type WidgetModule struct{}
func (WidgetModule) Groups() []rungrad.Group {
return []rungrad.Group{{ID: "widgets", Title: "Widgets:"}}
}
func (WidgetModule) Commands() []*rungrad.Command {
return []*rungrad.Command{{
Use: "widget",
Short: "Work with widgets",
GroupID: "widgets",
}}
}
func (WidgetModule) Catalog() []rungrad.CommandSpec {
return []rungrad.CommandSpec{{
Path: "widget",
Summary: "Work with widgets",
GroupID: "widgets",
}}
}
app.AddModule(WidgetModule{})
App.AddModule registers module groups through the same path as App.AddGroup,
registers the module’s top-level commands through App.AddCommand, and stores a
deep copy of the module catalog. Re-registering the same help group
ID with the same title is ignored so modules can share groups. Reusing a group
ID with a different title panics. help is always a reserved command name.
completion is reserved unless AppConfig.Surface.Completion is host-owned.
__rungrad_manifest is reserved only when rungrad registers the default
manifest endpoint; a renamed endpoint reserves its configured name instead.
Reserved names are rejected anywhere in an added command tree because they are
internal or intentionally absent from the ordinary command surface.
CommandSpec is deliberately independent of Command: it restates the
docs-facing command contract, including Path, Summary, GroupID,
OutputModes, Examples, Related, RequiresAuth, Mutates, Destructive,
SupportsMeta, and Extensions. CommandSpec.Extensions mirrors
Command.Extensions; ValidateCatalog canonical-encodes both sides and reports
drift when product metadata no longer matches the built command. Use
testutil.AssertConsistent from a unit test to catch catalog, help, docs, and
manifest drift:
func TestCommandCatalog(t *testing.T) {
testutil.AssertConsistent(t, newApp)
}
The consistency helper compares the catalog to the built Cobra tree through the
same metadata projection used by the manifest and docs generator. It also checks
that manifest-declared examples, related commands, output modes, authentication,
mutation/destructive markers, and metadata support are present in help and
generated docs. When the catalog includes extensions, the same path checks
extension drift. That keeps the catalog, __rungrad_manifest, help, and the
generated command reference on one command surface.
For a compiled-module reference, see cmd/rgref/main.go:
itemsModule declares the core help group and item commands,
identityModule registers whoami, and updateSpecModule supplies the catalog
row for the framework-built update command. The seven-row reference catalog is
gated by TestReferenceCatalogCoversVisibleCommands in
cmd/rgref/catalog_test.go and by
TestHelpDocsManifestConsistent in
cmd/rgref/docs_test.go.
ValidateCatalog is a whole-surface gate: every visible command needs a
CommandSpec. Specs are contributed through AddModule; if you also register a
command directly with AddCommand, cover it with a spec-only module or skip
catalog validation. CommandSpec.Examples uses the same line-oriented shape as
the manifest, so provide one example per slice element rather than one string
containing newlines.
Runtime plugins are out of scope. Feature modules are compiled into the binary and registered explicitly; rungrad never discovers or loads modules at runtime.
Framework surface customization
AppConfig.Surface controls which public surfaces rungrad owns, which the host
owns, and which are disabled. The zero value preserves the default rungrad-owned
behavior.
SurfaceMode has three values:
SurfaceRungradOwned("rungrad") keeps the default framework surface.SurfaceHostOwned("host") suppresses rungrad’s public surface where applicable so the product can provide its own contract.SurfaceDisabled("disabled") omits that surface.
Global flags are all-or-nothing through Surface.GlobalFlags. In
rungrad-owned mode, rungrad registers its flags. In disabled mode, it
registers none of them; product-local flags named --json, --jq, or
--template do not trigger rungrad machine-mode error rendering. In host-owned
mode, provide a GlobalFlagBindings entry for every rungrad-recognized flag
that is active for the app. The binding names may be renamed or hidden, and they
still drive rungrad output modes, config/profile/auth-file resolution, service
overrides, terminal/pager controls, prompts, and machine-mode error rendering.
Bindings for json, jq, and template cannot define shorthands because the
early raw-argument machine detector tracks long names only. Hidden host bindings
work at runtime but are omitted from the manifest and generated docs.
Advanced embedders call app.BindGlobalFlags(fs, bindings) to register the
same host-owned binding logic on a chosen *pflag.FlagSet. New calls this
automatically when Surface.GlobalFlags.Mode is SurfaceHostOwned; malformed
binding sets return an error from BindGlobalFlags and panic from New.
Version ownership is controlled by Surface.Version. Rungrad-owned mode enables
Cobra --version from AppConfig.Version; host-owned and disabled modes leave
Cobra’s version flag suppressed. The manifest tool_version still comes from
AppConfig.Version.
Completion ownership is controlled by Surface.Completion. Rungrad-owned mode
keeps Cobra’s generated completion command and filters it from docs, manifests,
and catalog validation. Host-owned mode disables Cobra’s generated command and
allows a visible product completion command, which is included in docs,
manifests, and catalog validation like any other command. Disabled mode disables
Cobra completion and keeps the name reserved.
The manifest endpoint uses Surface.Manifest:
| Mode | Valid configuration | Behavior |
|---|---|---|
ManifestEndpointRungradOwned |
Name == "" and Render == nil |
Registers hidden __rungrad_manifest and uses rungrad’s JSON renderer. |
ManifestEndpointDisabled |
Name == "" and Render == nil |
Registers no endpoint and reserves no manifest endpoint name. |
ManifestEndpointRenamed |
Non-empty single-token Name; Render == nil; Name must not be __rungrad_manifest, help, an active reserved command name, or an existing top-level command. |
Registers a hidden endpoint at Name and uses rungrad’s JSON renderer. |
ManifestEndpointHostRendered |
Name == "" and Render != nil |
Registers hidden __rungrad_manifest and calls Render(ManifestEndpointContext) error. |
For host-rendered endpoints, the context includes the current Cobra command, the typed manifest, and a staged stdout writer. If the renderer returns an error, staged bytes are discarded and rungrad reports the error; there is no fallback to the default manifest bytes.
Call app.ManifestDocument() for the typed manifest.Manifest
document directly from a framework-built tree. If you are inspecting or mutating
raw Cobra annotations manually, call app.ManifestDocumentChecked() so malformed
rungrad.extensions annotation JSON is returned as an error instead of treated
as a programmer panic. The default hidden endpoint and host-rendered endpoints
use the checked builder before rendering.
The conformance scorer discovers only the default __rungrad_manifest endpoint.
Under --manifest auto or --manifest required, scoring uses rungrad-owned and
host-rendered default endpoints when discovery succeeds. Disabled or renamed
endpoints are valid customized framework surfaces, but scoring treats them as
black-box targets in --manifest auto or when the caller explicitly uses
--manifest off; --manifest required fails when the default endpoint is
absent.
Output: one model, two forms
Never format twice. Build one result value and hand it to the Factory:
func(f *rungrad.Factory, cmd *cobra.Command, args []string) error {
return f.WriteResult(widgets, func(w io.Writer) {
output.RenderTable(w, output.Table{
Columns: []string{"ID", "Name"},
Rows: rows,
Empty: "No widgets.",
})
})
}
WriteResult(model, human) emits output.StableJSON(model) under --json,
otherwise it calls the human renderer. Because both come from one call, the text
and machine forms stay aligned. The output package also provides Node (for
key/value detail views) and MutationSummary (for create/update/delete results),
plus RenderNodes, RenderTable, and RenderMutation.
For copyable WriteResult examples, compare rgref item get and rgref whoami
in cmd/rgref/main.go. Their generated command
references are
cmd/rgref/docs/rgref_item_get.md and
cmd/rgref/docs/rgref_whoami.md; their
exact help transcripts are
cmd/rgref/testdata/help/rgref_item_get.txt
and
cmd/rgref/testdata/help/rgref_whoami.txt.
TestItemGetUnique and TestWhoamiNeverPrintsRawToken in
cmd/rgref/main_test.go cover the shipped
behavior.
For terminal-aware human output, output also provides
RenderMutationMode(w, summary, mode) and DryRunPreview.RenderMode(w, mode).
These color only the mutation action word and the DRY RUN label respectively,
and only when the output.TerminalMode has both ANSI and Color set. The plain
RenderMutation and Render helpers are the same renderers with a zero
TerminalMode. Factory.TerminalMode() enables styling only for human output to
a stdout terminal. JSON, jq, template, explicit plain output, piped output,
and ordinary test output stay escape-free. WritePreview already renders
previews through RenderMode(f.Stdout, f.TerminalMode()), while
mutation-summary color is opt-in by rendering with RenderMutationMode.
In advanced-output apps, users refine terminal behavior without changing
command code. --no-color disables color while leaving non-color ANSI available.
--no-ansi disables all terminal control bytes in human output and disables the
pager. --no-pager disables pager use only. Human output to non-terminal stdout
is sanitized the same way as --no-ansi, so copied or redirected output does not
contain raw control bytes even if a data field contains them.
rgref item list is the reference human-output paging fixture. The command code
lives in cmd/rgref/main.go, global terminal and pager
flags are listed in cmd/rgref/docs/index.md, and
the exact root and command help bytes are pinned in
cmd/rgref/testdata/help/rgref.txt and
cmd/rgref/testdata/help/rgref_item_list.txt.
TestItemListPagerPolicy in
cmd/rgref/advanced_output_test.go
proves when human output pages and when machine/plain output does not.
StableJSON is deterministic: two-space indentation, sorted map keys, and a
trailing newline, byte-identical across runs. Keep timestamps and random ids out of
default output so the determinism rules hold. output.Node entries marked Prose
are omitted from JSON, including when they are nested inside ordinary structs,
maps, slices, pointers, interfaces, or a node’s own Value passed to
StableJSON. If a model contains a reference cycle (a value that points back to
itself), StableJSON returns a cycle detected in result model error instead of
overflowing the stack, and the command exits with the API/runtime code (2).
Advanced output: plain, jq, and template
Advanced output is opt-in at the app level:
app := rungrad.New(rungrad.AppConfig{
Name: "mytool",
Short: "mytool CLI",
AdvancedOutput: true,
})
Declare support per command with OutputModes:
OutputModes: []string{
rungrad.OutputModeHuman,
rungrad.OutputModeJSON,
rungrad.OutputModePlain,
rungrad.OutputModeJQ,
rungrad.OutputModeTemplate,
},
WriteResult keeps its compact signature and, in an advanced app, also serves
--jq and --template from the same stable JSON model used by --json. Use
WriteOutput when a command declares plain, because --plain requires an
explicit copy-safe renderer:
return f.WriteOutput(rungrad.Output{
Model: widgets,
Human: func(w io.Writer) { output.RenderTable(w, table) },
Plain: func(w io.Writer) { renderWidgetNames(w, widgets) },
})
The full reference fixture is rgref item list: it supports --plain,
--jq '.[].name', and
--template '{{range .}}{{.id}} {{.name}}{{"\n"}}{{end}}' through
WriteOutput. rgref item get alpha --jq .id and
rgref item get alpha --template '{{.id}}' demonstrate WriteResult transform
support. See the command source in
cmd/rgref/main.go, the generated pages
cmd/rgref/docs/rgref_item_list.md and
cmd/rgref/docs/rgref_item_get.md, the
help goldens
cmd/rgref/testdata/help/rgref_item_list.txt
and
cmd/rgref/testdata/help/rgref_item_get.txt,
and TestItemListPlain, TestItemListTransformsPreserveModel, and
TestItemGetTransforms in
cmd/rgref/advanced_output_test.go.
The guard rejects unsupported or conflicting modes before the command handler
runs: --jq and --template are mutually exclusive; --plain cannot combine
with --json, --jq, or --template; --plain is refused unless the command
declares OutputModePlain; and transforms are refused unless the command
declares OutputModeJQ or OutputModeTemplate.
--jq and --template transform the exact stable JSON bytes that --json
would emit. An invalid jq expression or template is a usage error (exit 1). A
transform that fails while executing on the result data is a runtime/API error
(exit 2). On any failure, stdout is empty.
Transform modes are machine-output modes. While --jq or --template is
active, framework prompts are disabled, Infof hints are suppressed, and
Factory.TerminalMode() stays plain. JSON, jq, template, and plain output are
escape-free and never paged.
Transform output is deterministic for identical inputs: stable JSON goes in,
gojq emits compact JSON with sorted object keys, templates normalize to one
final newline, and results are fully buffered before stdout is written. The
output boundary sanitizes template output too, so a template that prints a raw
control byte from a string field still produces copy-safe stdout.
Advanced human output is also buffered so long listings can be paged. The pager
is selected from <TOOL>_PAGER, then PAGER, then less -FRX on non-Windows
systems. Values are split on whitespace and are not shell-evaluated. If a pager
cannot start, rungrad falls back to writing the already-rendered output directly
to stdout.
The rgref item list docs and help transcript named above show the user-facing
advanced-output modes for the same command that TestItemListPagerPolicy uses
as the paging fixture. The reference index
cmd/rgref/docs/index.md and root help golden
cmd/rgref/testdata/help/rgref.txt
pin the global terminal and pager flags.
Metadata envelope (–include-meta)
To expose request metadata in machine output, declare the capability per command:
&rungrad.Command{
Use: "show <id>",
OutputModes: []string{rungrad.OutputModeHuman, rungrad.OutputModeJSON, rungrad.OutputModeJQ, rungrad.OutputModeTemplate},
SupportsMeta: true,
Run: func(f *rungrad.Factory, cmd *cobra.Command, args []string) error {
widget, meta, err := client.GetWidget(args[0])
if err != nil {
return err
}
return f.WriteResultWithMeta(widget, meta, func(w io.Writer) {
renderWidget(w, widget)
})
},
}
Build output.Meta from the response information your API reports. Keep secrets
out of metadata because it is emitted verbatim. Use RequestID for the primary
request and RequestIDs when retries, redirects, or upstream services return
more than one request id:
func metaFromResponse(resp *http.Response, page PageInfo, attempts int, waits []int64) output.Meta {
requestID := resp.Header.Get("X-Request-Id")
return output.Meta{
RequestID: requestID,
RequestIDs: []string{requestID},
Pagination: &output.Pagination{
NextCursor: page.NextCursor,
HasMore: &page.HasMore,
},
RateLimit: &output.RateLimit{
Limit: parseInt64Header(resp.Header, "X-RateLimit-Limit"),
Remaining: parseInt64Header(resp.Header, "X-RateLimit-Remaining"),
Reset: parseInt64Header(resp.Header, "X-RateLimit-Reset"),
Raw: map[string]string{
"X-RateLimit-Limit": resp.Header.Get("X-RateLimit-Limit"),
"X-RateLimit-Remaining": resp.Header.Get("X-RateLimit-Remaining"),
"X-RateLimit-Reset": resp.Header.Get("X-RateLimit-Reset"),
},
},
Retry: &output.Retry{Attempts: attempts, WaitsMS: waits},
Idempotency: &output.Idempotency{Key: resp.Header.Get("Idempotency-Key")},
Extra: map[string]any{"region": resp.Header.Get("X-Region")},
}
}
Under --include-meta, rungrad wraps the machine value as {data, meta}.
--json and any supported --jq or --template transforms see that envelope,
so --jq '.meta' and --jq '.data' both work for commands that declare jq
support. Without --include-meta, output is the bare result model. If no
metadata is attached, --include-meta still yields a deterministic {"data": ..., "meta": {}} shape.
When a command also declares plain, use WriteOutput and set Output.Meta.
Human output and --plain are never wrapped; a command may still render captured
metadata in its human view.
The reference command is rgref item list: it sets SupportsMeta, attaches
Output.Meta, and makes the envelope visible to
rgref item list --include-meta --json,
rgref item list --include-meta --jq '.meta.request_id', and
rgref item list --include-meta --template '{{.meta.pagination.total_items}}'.
See cmd/rgref/main.go, the generated page
cmd/rgref/docs/rgref_item_list.md, the
help golden
cmd/rgref/testdata/help/rgref_item_list.txt,
and TestItemListIncludeMetaEnvelope plus
TestItemListIncludeMetaTransformsSeeEnvelope in
cmd/rgref/metadata_test.go.
The guard rejects --include-meta before the handler runs when the command does
not declare SupportsMeta, when no machine-output mode (--json, --jq, or
--template) is active, when combined with --plain, or when combined with
--dry-run.
Dry run
A mutating command builds a preview and returns it under --dry-run:
if f.DryRun() {
return f.WritePreview(output.DryRunPreview{
Method: "POST", Path: "/widgets",
Body: []output.Field{
{Name: "name", Value: args[0]},
{Name: "token", Value: secret, Secret: true}, // masked everywhere
},
})
}
Fields marked Secret are masked in both the human and JSON forms, so a preview
never leaks a credential. On a terminal, WritePreview highlights the DRY RUN
label; under --json or when piped the preview is plain. Without --dry-run,
perform the write and return a MutationSummary through WriteResult.
For destructive actions, mark the command Destructive: true, register a local
--confirm flag through Configure, and gate the action behind
f.ConfirmDestructive:
&rungrad.Command{
Use: "delete <name>",
Destructive: true, // implies Mutates: the command honors --dry-run
Configure: func(cmd *cobra.Command) {
cmd.Flags().Bool("confirm", false, "Confirm the destructive action without a prompt")
},
Run: func(f *rungrad.Factory, cmd *cobra.Command, args []string) error {
preview := output.DryRunPreview{Method: "DELETE", Path: "/widgets/" + args[0]}
if f.DryRun() {
return f.WritePreview(preview)
}
confirmed, _ := cmd.Flags().GetBool("confirm")
if err := f.ConfirmDestructive(rungrad.ConfirmOptions{
Action: "delete widget", Target: args[0], Confirmed: confirmed,
}); err != nil {
return err
}
// perform the delete, then report a MutationSummary through WriteResult
},
}
ConfirmDestructive is safe in every mode: under --dry-run it returns without
prompting; with --confirm it proceeds; on a terminal it prompts on stderr and
proceeds only on y/yes; and under machine output (--json, --jq, or
--template), --no-prompt, or no terminal it refuses with the usage exit code
(1) instead of blocking, so an agent is never stuck. The refusal body is the
standard JSON error on stderr under --json.
Name resolution
Accept human names where your API wants ids:
id, err := f.Resolve(args[0], lookup, resolve.Options{
ResourceType: "widget",
AllowPrompt: true,
IsID: resolve.IsNumericID,
})
lookup is a func(name string) ([]resolve.Match, error). f.Resolve wires the
interactive decision from the global flags: it disambiguates with a numbered prompt
on a terminal, and under --no-prompt or machine output (--json, --jq, or
--template) it returns an AmbiguousError listing the candidates instead of
blocking. A NotFoundError maps to the not-found exit code; an AmbiguousError
maps to the usage code.
Config and credentials
The Factory carries a config.Store resolved from --config and the tool name.
The validate-then-auth pre-run hook loads the credential for commands marked
RequiresAuth into f.Token. Credentials are read with env-then-file
precedence, stored in a separate 0600 file, and masked for display with
config.Mask. Never print f.Token raw; print config.Mask(f.Token).
Profiles, paths, and service endpoints
To use product-style profile, auth-file, or endpoint resolution, set
AppConfig.Resolution:
app := rungrad.New(rungrad.AppConfig{
Name: "mytool",
EnvVar: "MYTOOL_TOKEN",
Resolution: &rungrad.ResolutionConfig{
Profile: true, // registers --profile and enables MYTOOL_PROFILE
AuthFile: true, // registers --auth-file and enables MYTOOL_AUTH_FILE
Services: []rungrad.Service{{
Name: "api",
Flag: "api-url",
EnvVar: "MYTOOL_API_URL",
ConfigKey: "base_url",
Default: "https://api.example.com",
Usage: "API base URL",
}},
},
})
Resolution is additive. Apps without Resolution keep the compact flag surface.
When enabled, --config also gets an env tier from <TOOL>_CONFIG.
<TOOL> is derived from the app name with non-alphanumeric bytes collapsed to
underscores and uppercased, the same rule used for <TOOL>_PAGER.
The full profile, config path, auth-file path, and service-endpoint precedence details live in Config and auth. Read resolved values in command code with:
profile := f.Profile()
configPath := f.ConfigPath()
authPath := f.AuthFilePath()
api, ok := f.Service("api")
Default config-backed service values come from Profile.BaseURL for
ConfigKey: "base_url", then profile/global Services maps for arbitrary
endpoints, then Defaults as a compatibility fallback. URL derivation, such as
deriving one endpoint from another, belongs in your adopter code.
For a concrete reference, rgref configures service name api, flag
--api-url, env RGREF_API_URL, config key api_url, and built-in default
https://api.rgref.invalid in
cmd/rgref/main.go. Its item list command reads
f.Service("api") and exposes the resolved value in metadata. The user-facing
flag is pinned in cmd/rgref/docs/index.md and
cmd/rgref/testdata/help/rgref.txt;
TestItemListServiceURLPrecedence in
cmd/rgref/metadata_test.go covers the
reference command, while TestResolutionServiceAndProfilePrecedence and
TestFlaglessServiceResolvesEnvConfigAndBuiltin in
auth_resolution_test.go cover the framework
resolver.
Config loading
The default loader reads rungrad’s config.yaml into config.Config. For
product-owned config formats, set ResolutionConfig.LoadConfig to read the file
and normalize it into config.Config before generic profile and service
precedence runs. Missing config files are treated as an empty
Config{Version: 1}.
Custom credential resolution
Set AppConfig.Auth to a CredentialResolver when the default
env-then-stored-credential behavior is not enough. The resolver receives an
AuthContext with the resolved profile, config path, auth-file path, env var,
configured config.Store, injected env lookup, resolved services, and
RegisterSecret.
Return a rungrad.Credential with the primary token, source label, optional
display label, and adopter-defined Extra payload. The framework sets
f.Token, exposes the full value through f.Credential(), and auto-registers
only Credential.Token for redaction. Any secret material placed in
Credential.Extra or discovered elsewhere must be registered through
AuthContext.RegisterSecret.
Returning config.ErrMissingCredential exits 3. Returning config.Error
exits 1. Returning a rungrad.Error or any error with ExitCode() int uses
that code; other errors exit 2. The default resolver reports malformed or
unreadable local credential files as structured config errors, so they exit 1.
Browser login
Use f.OpenBrowser(ctx, url) to open the user’s browser. It calls the injected
Factory.BrowserOpener when set, otherwise browser.Open. Tests inject the
same hook through testutil.Options.BrowserOpener.
The browser.LoginFlow helper owns the generic open-then-poll loop: it opens
AuthURL once, then calls your poll function on a fixed interval until done,
poll error, or context cancellation. The device protocol, verification
endpoint, credential storage, and API validation remain product code.
The generic browser pieces are browser.Open and
browser.LoginFlow, with Factory.OpenBrowser in
auth.go and testutil.Options.BrowserOpener in
testutil/testutil.go. TestLoginFlowRunSuccess,
TestLoginFlowPollError, TestLoginFlowOpenError,
TestLoginFlowSleepCancellation, and
TestLoginFlowCanceledContextStopsBeforeOpenOrPoll in
browser/browser_test.go cover the open/poll
loop and cancellation behavior. TestBrowserOpenerInjection in
auth_resolution_test.go covers the factory hook.
TestCustomCredentialResolverAndRedaction in the same file proves that a custom
CredentialResolver can register an extra secret through
AuthContext.RegisterSecret and have it redacted at output boundaries, which is
the behavior a browser or device login relies on after it captures a token.
Structured config errors
config.Error reports malformed config/path resolution, invalid profile names,
invalid services, and malformed or unreadable local credential files discovered
by the default resolver. These are usage/configuration faults and map to exit 1
through the same ExitCode() interface used by adopter errors.
Secret redaction
The credential loaded for a RequiresAuth command is registered for output
redaction automatically. Register any other runtime secret as soon as command,
auth, or config code discovers it:
f.RegisterSecret(token)
Use this for environment tokens, browser-login results, generated keys, and API
error bodies that echo a credential. A registered value is replaced with
[REDACTED] at every framework-owned output boundary:
WriteResult/WriteOutput/WriteResultWithMeta/WritePreview in human,
--json, --plain, --jq, --template, and --include-meta forms; JSON and
text errors; Infof; the resolve disambiguation prompt; the destructive
confirmation prompt; and pager input. Redaction runs before the pager is invoked.
Resolve prompt redaction covers the default prompter installed by
Factory.Resolve; if you pass a custom resolve.Prompter, that prompter owns
its own writes and must avoid printing secrets or apply equivalent redaction.
Empty, whitespace-only, and values shorter than five bytes are ignored to avoid false positives. Register opaque string secrets. JSON output remains valid because only string contents are rewritten; a registered value emitted as a JSON number, boolean, or null is not rewritten in machine output. Redaction is deterministic, so repeatable-output guarantees still hold. Numeric-looking secrets are still redacted in human, plain, template, and text-error output when they are printed as text; the non-string scalar limitation applies only to JSON machine values.
Dry-run field masking (output.Field{Secret: true}) and config.Mask are
independent and still recommended. Redaction is the final safety pass over all
outbound framework text.
The shipped reference behavior is rgref whoami: it prints config.Mask(f.Token)
instead of the raw token in cmd/rgref/main.go. Its
generated page and help golden are
cmd/rgref/docs/rgref_whoami.md and
cmd/rgref/testdata/help/rgref_whoami.txt;
TestWhoamiNeverPrintsRawToken in
cmd/rgref/main_test.go proves shipped output does
not carry the raw credential. The automatic raw-token safety net is proved
separately by the test-only leak app in
cmd/rgref/redaction_test.go, especially
TestAutoRegisteredAuthTokenRedactedAcrossBoundaries. That raw-token command is
not shipped behavior; it exists only to prove framework redaction. Broader
framework boundary coverage lives in redaction_test.go.
Use f.Infof for non-essential progress or hints on stderr. It is suppressed
under --quiet and machine output (--json, --jq, or --template), so a
successful machine-output run carries no human-only guidance on stderr. Do not
put primary command results behind Infof; route those through
WriteResult or WritePreview. Destructive prompts and JSON error bodies do not
go through Infof, so --quiet cannot hide a required prompt or error.
Self-update
Add a standard update command backed by your repository’s releases:
app.AddCommand(update.Command(update.CommandConfig{
CurrentVersion: version,
ToolName: "mytool",
Fetcher: update.GitHubFetcher{Owner: "you", Repo: "mytool"},
Apply: update.ReplaceExecutable,
}))
update --check reports status and exits 0 without touching the binary, with
--json emitting a machine result. For an offline or air-gapped tool, inject a
fetcher that returns a static release.
ToolName sets the program name shown in the standard update command’s examples
in --help, generated docs, and the manifest. Leave it empty only for tests or
generic examples, where it falls back to mytool.
rgref update is the standard command built with update.Command, offline
fixedFetcher, and ToolName: "rgref" in
cmd/rgref/main.go. Its generated page and exact help
transcript are cmd/rgref/docs/rgref_update.md
and
cmd/rgref/testdata/help/rgref_update.txt.
Manifest metadata is pinned in
cmd/rgref/manifest_test.go, including
TestManifestUpdateExamples, and generated docs/help consistency is pinned in
cmd/rgref/docs_test.go.
update.ReplaceExecutable downloads the selected HTTPS release asset with a
bounded client and replaces the running executable on platforms that support that
operation. It does not verify checksums or signatures; wrap Apply with your own
verification if your distribution process requires it.
Exit codes
The root classifies a returned error into a stable code: 0 success, 1 usage, 2
upstream/runtime, 3 auth, 4 forbidden, 5 not-found, 6 rate-limited. Return
rungrad.NewError(code, msg) to set a code explicitly, or return the framework’s
typed errors (resolve.NotFoundError, config.ErrMissingCredential, and so on)
and let the root map them.
Flag misuse is classified for you. If you mark a flag required
(cmd.MarkFlagRequired) or declare a flag group (MarkFlagsRequiredTogether,
MarkFlagsMutuallyExclusive, MarkFlagsOneRequired), a violating invocation exits
1 (usage). The pre-run validates flags before loading any credential, so a missing
required flag or an invalid flag group on a RequiresAuth command exits 1 (usage),
not 3 (auth).
Host-owned error rendering and exit codes
Use rungrad’s default error text, JSON envelope, and exit-code mapping for new
CLIs. When porting an established Cobra CLI with a public error contract, set
AppConfig.ErrorPolicy so the host preserves its stderr shape and
product-specific exit categories without filtering rungrad output.
ErrorPolicy.Classify receives an ErrorContext with DefaultExitCode, the
underlying Err, the resolved command path when Cobra found one, raw args, the
global-flag snapshot, and narrow resolved config/auth fields. Return a positive
code to override the process exit code; return zero or a negative value to keep
rungrad’s default classification.
ErrorPolicy.Render writes only to ErrorContext.Stderr. Return nil to fully
own stderr; rungrad will not append its default Error: line or JSON object.
Return a non-nil error to discard the host’s staged bytes and fall back to the
default renderer. The fallback preserves the original exit code and adds a
redacted renderer_error detail in machine output.
ErrorContext is deliberately read-only: Flags is a value snapshot, the
context exposes no *Factory, and credential display is masked. Use
RedactString, RedactText, and RedactJSON for any host-rendered output;
use RedactJSON for parseable machine envelopes. rungrad does not apply an
extra redaction pass to bytes from a successful host renderer, because only the
host knows whether those bytes are text or JSON. MachineOutput uses parsed
global flags after flag parsing succeeds, and falls back to raw args only for
bare command-resolution and flag-parse failures, so --json, --jq, or
--template can still render those early errors as machine output.
type productError struct {
Code int
Message string
}
func (e productError) Error() string { return e.Message }
app := rungrad.New(rungrad.AppConfig{
Name: "mytool",
ErrorPolicy: &rungrad.ErrorPolicy{
Classify: func(ctx rungrad.ErrorContext) int {
var pe productError
if errors.As(ctx.Err, &pe) {
return pe.Code
}
return ctx.DefaultExitCode
},
Render: func(ctx rungrad.ErrorContext) error {
if ctx.MachineOutput {
body, err := output.StableJSON(map[string]any{
"message": ctx.Err.Error(),
"code": ctx.ExitCode,
})
if err != nil {
return err
}
_, err = ctx.Stderr.Write(ctx.RedactJSON(body))
return err
}
_, err := fmt.Fprintf(ctx.Stderr, "mytool: %s\n", ctx.RedactString(ctx.Err.Error()))
return err
},
},
})
Testing
Run your own commands in-process and assert on the captured output:
res := testutil.Run(app, "widget", "list", "--json")
if res.Exit != rungrad.ExitSuccess { t.Fatal(res.Stderr) }
testutil.Run/RunWith capture stdout, stderr, and the classified exit code;
testutil.MockServer stands up an httptest server for an API client. Because
output is deterministic, tests pin it exactly.
Help goldens, docs sync, and consistency
Declare one update flag in your test package and pass it to the helpers:
var update = flag.Bool("update", false, "regenerate golden files")
func TestGeneratedDocsInSync(t *testing.T) {
testutil.AssertDocsInSync(t, newApp, *update, "docs")
}
func TestHelpGoldensInSync(t *testing.T) {
testutil.AssertHelpGoldens(t, newApp, *update, "testdata/help")
}
func TestHelpDocsManifestConsistent(t *testing.T) {
testutil.AssertConsistent(t, newApp)
}
testutil.AssertDocsInSync checks committed docsgen.Generate output and, when
-update is set, calls docsgen.Write to regenerate pages and prune orphans.
docsgen.Check returns a CheckResult with grouped Missing, Stale, and
Orphaned pages so failures identify the exact drift class.
testutil.AssertHelpGoldens captures exact --help bytes for every visible
command, one .txt golden per command. Help is captured through a non-terminal
buffer, so the output is deterministic and the framework applies no
normalization.
testutil.AssertConsistent needs no committed files. It captures the manifest,
generated docs, and help from fresh apps, verifies that every command’s manifest
metadata is reflected by help and docs, and runs App.ValidateCatalog when the
app declares a catalog.
Generated docs and help transcripts are different gates. docsgen pages are the
curated command reference checked by TestGeneratedDocsInSync; help goldens are
exact --help bytes checked by TestHelpGoldensInSync.
TestHelpDocsManifestConsistent and the catalog check verify that generated
docs, help, manifest metadata, and catalog rows agree on one visible command
surface.
The reference CLI exercises all three in
cmd/rgref/docs_test.go and
cmd/rgref/catalog_test.go; committed docs live
under cmd/rgref/docs/, and exact help goldens live under
cmd/rgref/testdata/help/. The same -update
flag regenerates docs and help goldens together.
Manifest
By default, apps built with rungrad.New expose __rungrad_manifest
automatically. The hidden command emits deterministic JSON generated from the
same command tree and metadata used by help and docs generation, without loading
credentials or running adopter handlers. Use AppConfig.Surface.Manifest to
disable, rename, or host-render that endpoint. See The rungrad manifest
for the protocol and schema. The reference manifest metadata and global flags
are pinned by TestManifestReferenceCommands, TestManifestReferenceOutputModes,
and TestManifestGlobalFlagsIncludeAdvanced in
cmd/rgref/manifest_test.go.
For product-owned metadata that needs to appear in the machine manifest but is not
part of rungrad’s core command contract, set Command.Extensions:
Extensions: manifest.ExtensionSet{
"example.com/product": {
"owner": "platform",
"status": "beta",
"docs_path": "docs/widget-list.md",
},
},
Attach extensions through the typed Command.Extensions and
CommandSpec.Extensions fields, not by writing raw rungrad.extensions
annotations. Namespaces must be lowercase example.com/product-style names;
rungrad/ and rungrad. are reserved. Values must be JSON-compatible objects
with no nulls, no non-finite numbers, no cycles, no custom JSON/text marshaling,
and no first-level field names that reuse core manifest command fields. The
manifest package exposes ValidateExtensionSet, EncodeExtensions, and
DecodeExtensions for the wire shape, plus RequireExtensionFields,
RequireExtensionEnum, and RequireExtensionDocPath for product tests. Use
those helpers against app.ManifestDocument() to assert product invariants such
as owner, lifecycle status, and docs-path rules.
Extensions are a boundary, not an override mechanism. Put docs/audit/ownership
metadata under product field names, and keep rungrad-owned semantics such as
mutates, supports_dry_run, destructive, and supports_meta in the core
fields. Generic manifest consumers tolerate valid unknown namespaces.
Starting from the scaffold
Use rungrad new <name> for the compact widget starter. It includes a widget
resource, text/JSON output, mutating dry-run behavior, destructive confirmation,
an offline update command, generated tests, and a README without making
product-level choices.
Use rungrad new <name> --product-profile for a product-shaped CLI: custom
env-var prefix, profile/auth-file/config resolution,
service endpoint flags/env/config keys, product labels, a manifest extension
namespace, and a choice of global-flag ownership through AppConfig.Surface.
The product scaffold also demonstrates manifest.ExtensionSet on a command and
keeps the default manifest endpoint available for offline scoring. It does not
enable advanced output or host error rendering by default.
For an existing Cobra CLI, generate a product scaffold with matching names and
service placeholders, then use it as a comparison target while porting by hand:
compare root AppConfig, global flag ownership, command metadata, output
rendering, dry-run previews, destructive confirmation, manifest extensions, and
update wiring. There is no automated rename, migration, or porting command.
Scoring yourself
Build your binary and run rungrad score against it (see
Conformance and the spec). A tool built with this guide should
score 100% on the rules that apply to it. TestReferenceCLIScoresPerfect in
cmd/rgref/conformance_test.go pins this
for the reference CLI.
Source: docs/building-a-cli.md in the rungrad repository.