57 KiB
Unified command registration API
Status: finalized API design; implementation is pending. The declarations, consumer examples, and design checks below define the selected target API, including the additions and clarifications selected in the finalized backend design. No implementation is authorized by this document alone.
Purpose and authority
Applications and integrations construct captured command declarations and install them through the same core API. Installation validates each batch before registry mutation. Integrations supply complete standard families and focused conveniences while retaining ownership of their provider semantics.
This document defines the selected registration API: capture declarations and any construction errors eagerly, then report errors at installation. The completed alternatives comparison is retained in Git history.
ADR 0001 owns explicit registration, typed inputs, and command ownership. ADR 0010 owns optional module boundaries and convenient integration registration. ADR 0011 records the captured-declaration model, optional-family namespace lifetime, and separated input/shared-state lifetimes. The architecture policy reflects those boundaries. The module roadmap tracks extraction work. The finalized backend design owns internal structure and shared implementation mechanics. Its resolved composition choices are incorporated below; this API design remains a target rather than implemented behavior.
Design for the best consumer API without preserving current implementation signatures or machinery. Existing code can supply evidence and reusable work, but does not constrain the design. This is future API design, not documentation of implemented behavior.
Selected consumer model
A consumer can use only the core or add any supported combination of integrations.
Every command reaches the application through core New or Register, regardless
of whether an application or integration declared it. There is no mandatory
integration interface, application-wide generic input type, or separate
integration installation lifecycle.
The core remains independent of integration modules and their upstream libraries.
Integrations may depend on one another as documented. Such module dependencies
do not install commands or acquire providers. The selected modules remain
CommandKit, CommandKit/integrations/ckconfig, and
CommandKit/integrations/ckprompt, under the existing repository module prefix.
Each application command retains its own typed input and domain handler.
Integration suites retain their own typed context and provider declarations.
Both produce the same opaque core Command handle. Consumers need not construct
individual standard handlers, duplicate integration adapters, or expose backend
objects to assemble an application.
Declaration and installation
One declaration model
A Command is a captured declaration, not proof that the declaration is valid
or installed. Ordinary command, group, scope, and suite adapter construction
does not require a separate error check at each call site.
The uniform construction shape is below. The complete declarations and examples later in this document provide the supporting fields and semantics for implementation.
// Core package:
func NewCommand[I any](spec CommandSpec[I]) Command
func Group(name, summary string, children ...Command) Command
func New(identity Identity, commands ...Command) (*App, error)
func (app *App) Register(commands ...Command) error
The initial integrations expose complete-family factories using their own suite types:
// In each initial integration package; Suite belongs to that integration:
func Command[I, S any](suite Suite[I, S]) commandkit.Command
Here I is the suite's CLI input and S is its shared invocation state. This
selected separation resolves the pointer-isolation issue without a clone hook or
an application-wide type. Future integrations can accept declarations suited to
their resources while returning the same core Command; the core does not
prescribe a universal suite.
Application-owned command factories follow the same declaration pattern. Application helpers that genuinely perform other fallible work may still return errors; the design does not suppress those errors or require all application functions to be infallible.
Eager capture, errors reported at installation
Factories capture declaration metadata and callback references and assemble neutral command descriptions immediately. Validation that can already be done may occur then, but its errors remain attached to the declaration for installation to report. Internally fallible assembly can capture an error without returning it to the consumer at every construction step.
Use the selected WithError(command, err) core operation to retain integration
construction errors in opaque declarations. Its complete signature and no-op
interaction are specified below. Do not substitute an untyped integration payload,
root-private access, or a deferred preparation lifecycle.
New and Register report retained declaration errors and validate the batch,
including bindings, root placement, and collisions, before registry mutation.
A failed New yields no usable application. A failed Register leaves the
existing registry intact. No rollback guarantee spans separate registration calls.
Declaration construction, installation, help, and version do not invoke provider acquisition or application/suite input/state factories. Built-in help may prepare its own private typed input through the common compiler, without initializing the target command or invoking its validation/execution callbacks. These paths do not validate files, credentials, or application configuration. Invalid wiring fails at installation; an upstream error returned by a valid provider remains an invocation result.
An originally nil loader remains invalid wiring even if an adapter would wrap it in a non-nil function. Integration declarations retain enough information to detect this. An intentionally absent capability is distinct from an invalid adapter. Errors identify the affected command family and relevant scope, binding, or capability and preserve supported wrapped error identity; exact prose is not selected here.
Integration composition and conveniences
Configuration scopes are composed before installation into one configuration suite. Application and engine scopes can share that suite. Named definition inspection remains independently selectable: installing an engine configuration scope does not automatically install definition commands, or vice versa.
Keep provider declarations integration-owned. Configuration supports ConfigKit for now; the core does not acquire a universal configuration or suite interface. The PromptKit integration can use the ConfigKit integration's selection and scope contracts through its selected dependency.
Provide the focused ckconfig.AppCommand(load) convenience for a context-free
application configuration loader. It declares the standard app scope, with a
default description, through the same declaration model as the full suite. It
does not require an unused context argument or Suite[struct{}] at the consumer
call site. A loader still receives context.Context and configuration selection.
Use the full suite when adding scopes, selectors, custom state, or other metadata.
Do not initially expose integration-specific Register or RegisterApp
functions alongside declaration factories. Core registration is the installation
path for all consumers. Additional conveniences should address demonstrated
consumer needs rather than mirror every underlying constructor automatically.
Namespaces and empty declarations
Help and version are reserved by core construction. Optional families claim their complete root namespace when installed. Before installation, a domain group may occupy that name; subsequent integration installation then fails with a collision. The reverse order also rejects a collision. Neither order replaces or merges an existing group.
Standard-family declarations carry enforceable root-only placement metadata.
Installing them beneath an ordinary group is an assembly error. Use the same
Command type with the selected RootGroup constructor specified below. It
represents placement, not authentication of the declaring package. Installed
standard families cannot be extended with private application operations or have
their handlers replaced.
The selected empty-suite behavior is:
| Declaration | Installation result |
|---|---|
| Configuration suite with no scopes | Declaration error |
| Named configuration scopes with no inspection operations | Install scope discovery with the actual empty capability sets |
| Configuration scopes with supplied operations | Install discovery and supported operations |
| Otherwise valid definition suite with no capabilities | Successful no-op; no namespace or help entry |
| Adapter with a nil loader | Declaration error, even when selected capabilities otherwise appear empty |
Use an explicit commandkit.NoCommand() declaration for intentional absence.
Core installation skips it. A zero-value Command remains invalid. Integration
factories retain adapter-construction errors before representing an empty family
as a no-op. Validate bindings needed by generated commands; unused selector
bindings are ignored when they contribute to no generated inspection command.
Integration-owned scope identifiers, applicability references, and enabled-provider
wiring still undergo validation. Ignoring unused bindings does not erase retained
adapter errors. This is the selected unused-binding resolution.
A no-op does not reserve a namespace, and NoCommand() needs no validation-bearing
overload or separate public declaration validator.
Capture, reuse, and invocation inputs
Capture scope metadata, capability lists, selector applicability, bindings, and callback references when declarations are constructed. Copy the declaration collections needed for those semantics. Later edits to the source suite or its metadata slices do not change a returned command declaration.
Declarations may be installed in multiple applications. Registries maintain independent installed state, and invocations have independent parsed state. Application callback closures and objects they reference remain application-owned and may deliberately be shared. These choices do not promise arbitrary deep copies or concurrent dispatch.
Use the same typed, presence-aware binding primitives for application commands and integration inputs. Configuration selectors additionally declare scope applicability; definition selectors need no configuration-specific wrapper. Explicit empty, false, and zero values remain distinguishable from omission.
An omitted NewInput or NewState supplies the zero value of its respective
type. For pointer types that is nil; do not silently allocate arbitrary pointer
state or prohibit a loader that intentionally accepts nil. Applications supply a
factory when initialized state is needed. A supplied factory returning nil retains
its application-owned meaning.
Scope-specific parsed values stay separate from shared acquisition state through
the selected Suite[I, S]: NewInput initializes each selected scope's input,
while NewState initializes S once for the selected inspection invocation.
Providers receive both explicitly. Pointer inputs are independently initialized,
not shallow copies of a single pointer. The detailed lifetime table below
spells out those calls without requiring arbitrary deep copies or prohibiting
pointer inputs or shared pointer state.
Selected core declarations
The following declarations specify the public registration and binding surface
used by these examples. Opaque types retain unexported representation; these are
API declarations, not an implementation of their bodies. context and io are
standard-library packages. Outcome semantics remain governed by
ADR 0005; this pass changes neither status
mapping nor diagnostic delivery policy.
package commandkit
import (
"context"
"io"
)
type App struct {
// Unexported representation.
}
type Command struct {
// Unexported representation.
}
type Flag[I any] struct {
// Unexported representation.
}
type Argument[I any] struct {
// Unexported representation.
}
type Identity struct {
Name string
Version string
Revision string
}
type Streams struct {
Out io.Writer
Err io.Writer
}
type Invocation struct {
Context context.Context
Streams Streams
}
type Supplied[T any] struct {
Value T
Set bool
}
type FlagMeta struct {
Name string
Short string
Usage string
Required bool
}
type ArgumentMeta struct {
Name string
Usage string
}
type CommandSpec[I any] struct {
Name string
Summary string
Description string
Children []Command
NewInput func() I
Flags []Flag[I]
Arguments []Argument[I]
CheckInput func(I) error
Run func(Invocation, I) Outcome
}
func NewCommand[I any](spec CommandSpec[I]) Command
func Group(name, summary string, children ...Command) Command
func RootGroup(name, summary string, children ...Command) Command
func WithError(command Command, err error) Command
func NoCommand() Command
func New(identity Identity, commands ...Command) (*App, error)
func (app *App) Register(commands ...Command) error
func (app *App) Execute(ctx context.Context, args []string, streams Streams) Outcome
func ValueFlag[I, V any](
meta FlagMeta, defaultValue *V,
parse func(string) (V, error), set func(*I, Supplied[V]),
) Flag[I]
func BoolFlag[I any](
meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool]),
) Flag[I]
func RepeatFlag[I, V any](
meta FlagMeta, defaults []V,
parse func(string) (V, error), set func(*I, Supplied[[]V]),
) Flag[I]
func ValueArgument[I, V any](
meta ArgumentMeta, required bool,
parse func(string) (V, error), set func(*I, Supplied[V]),
) Argument[I]
func RepeatArgument[I, V any](
meta ArgumentMeta, min, max int,
parse func(string) (V, error), set func(*I, Supplied[[]V]),
) Argument[I]
type Severity string
const (
SeverityWarning Severity = "warning"
SeverityError Severity = "error"
)
type Diagnostic struct {
Severity Severity
Write func(io.Writer) error
}
// Neutral value/diagnostic envelope; not a universal provider interface.
type Acquired[T any] struct {
Value T
Diagnostics []Diagnostic
}
type OutcomeKind string
const (
OutcomeSuccess OutcomeKind = "success"
OutcomeUsage OutcomeKind = "usage"
OutcomeOperational OutcomeKind = "operational"
OutcomeDomain OutcomeKind = "domain"
)
type DeliveryState string
const (
DeliveryNone DeliveryState = "none"
DeliveryComplete DeliveryState = "complete"
DeliveryIncomplete DeliveryState = "incomplete"
)
type Outcome struct {
Kind OutcomeKind
Status int
Domain string
Cause error
DeliveryError error
DiagnosticDelivery DeliveryState
ReportIncomplete bool
Diagnostics []Diagnostic
}
func Success() Outcome
func Failure(cause error) Outcome
NewCommand records invalid bindings or invalid execution wiring as declaration
errors; it does not require a construction error check. An ordinary group may
have no execution handler. Opaque declarations expose no operation to mutate an
installed tree, extract its handlers, or append children after capture. Collections
in CommandSpec, groups, and defaults are captured as appropriate; runtime
closure state and diagnostic writer effects remain caller-owned.
Runnable parents and captured children
CommandSpec.Children combines a typed handler with child declarations. Capture
its child-handle slice and apply the same immediate composition, collision, and
installation placement checks used by Group. A runnable parent follows its
handler and operand requirements on bare invocation; help describes the parent
and children under ADR 0006. Group remains the
convenience for a non-runnable parent. Neither form permits mutation or child
append operations after capture. The backend design
uses the same captured representation and installation path for both.
Retained errors, placement, and no-ops
WithError returns a new captured declaration retaining a non-nil error alongside
any errors already attached to the declaration or its children. A nil error does
not add an error. Installation reports retained errors with command context and
preserves their supported errors.Is/errors.As identity. It need not prescribe
an exact aggregate-error type or error text. Wrapping does not turn an invalid
zero declaration into a valid command or erase a root-only marker.
RootGroup has the same eager declaration behavior as Group, but carries
root-only placement metadata. Core installation rejects it anywhere below the
root, including inside a wrapper declared with ordinary Group. No integration
name allowlist or declaring-package authentication is needed.
NoCommand is an explicit absence marker. Core checks attached errors before
skipping it, so WithError(NoCommand(), err) fails rather than hiding err.
An unadorned no-op is skipped at the root or among children and contributes no
help entry or reserved namespace. A zero-value Command is not this marker and
fails installation. A root-only group with no children remains a real group,
not an absence marker; the integrations select their own empty-suite semantics.
These operations compose without private-state access or delayed preparation. An integration can construct an ordinary neutral tree, mark its root, and attach its own validation error:
// Schematic integration-owned assembly: every function called here runs eagerly.
func familyDeclaration(children []commandkit.Command, wiringErr error) commandkit.Command {
group := commandkit.RootGroup("config", "Inspect configuration.", children...)
return commandkit.WithError(group, wiringErr)
}
The actual integration factory validates scope/provider metadata and eagerly
constructs all supported children. Core constructors retain their own binding
errors. There is no exported preparation callback, invocation-time declaration
validation, or root helper that understands ckconfig.Suite.
Typed flag composition needed by optional modules
Scopes need to apply the same parsed selector to independently initialized inputs.
The integration cannot inspect a core Flag's private parser or setter. Provide
one narrow typed composition mechanism, rather than exporting those internals:
// In commandkit:
type FlagValues[I any] struct {
// Unexported representation.
}
// Non-nil sentinel errors; compare using errors.Is, including through wrapping.
var (
ErrFlagInput error // Missing required selected flag value.
ErrFlagWiring error // Invalid binding selection or destination.
)
func CaptureFlags[Outer, I any](
flags []Flag[I],
target func(*Outer) *FlagValues[I],
) []Flag[Outer]
func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error
CaptureFlags eagerly captures bindings and supplies ordinary outer-command flag
declarations. During an execution invocation those declarations record converted
values, defaults, and presence in the targeted FlagValues; they do not initialize
I or acquire providers. Nil target callbacks and invalid inner bindings remain
declaration errors. The core's ordinary flag validation still catches duplicate
names and conflicts with standard flags after composition.
ApplyTo delivers only the selected bindings into an already initialized input.
The integration selects those bindings from its captured scope-applicability
metadata. Binding handles carry sufficient identity to match their captured
values without string-based retrieval by handlers. Selecting a binding absent
from the captured set or providing a nil destination pointer is an application/
integration wiring error. The zero FlagValues with no selected bindings is valid.
Copying a binding handle or capturing it in a command preserves its identity. Constructing another flag with identical metadata creates a distinct binding; matching names, parsers, or setters do not make it interchangeable with the original. Reuse the original handles when selecting values to apply. The same handle can be captured by separate declarations without sharing invocation values.
Execution-required checks for captured inner flags are deferred until ApplyTo
selects the applicable subset; a required app-only selector must not become
mandatory for a PromptKit-only scope request. Missing required selected values
match ErrFlagInput through errors.Is and produce usage outcomes. An absent
binding or nil destination matches ErrFlagWiring and produces an operational
failure, not an operator usage error. Each error produced by ApplyTo identifies
one of these categories and includes relevant flag context without requiring
callers to parse its message.
The core's CheckInput error handling recognizes ErrFlagWiring through wrapping
and routes it to the operational path. ErrFlagInput and ordinary input-validation
errors use the usage path. Integrations may therefore return an ApplyTo error
from their input-validation path, adding family/scope context by wrapping it,
without misclassifying wiring defects. Both failures stop before shared-state
initialization or acquisition. Metadata wiring defects detectable during assembly
remain retained installation errors; these categories do not defer those checks
to execution. Help uses metadata without applying values or requiring execution
inputs.
Core-owned repeatable-value slices are independent per application of the values; custom parsed objects and accessor effects retain application ownership. This API does not deep-copy arbitrary application values or expose backend state. It is a reusable typed input-composition contract for integrations, not a second parser, a raw-argv handler API, or an untyped integration payload.
For example, the private configuration command input can contain
FlagValues[I] beside standard selection/scope fields. The integration declares
its selector flags through CaptureFlags, resolves selected scopes in its
execution input-validation path, and calls ApplyTo for each scope. Definition
inspection applies the whole captured flag set once. This explains the public
seam; private input structs and the exact assembly algorithm remain implementation
choices.
Selected configuration integration declarations
The ckconfig package owns the following contracts. Only the four implemented
inspection operations appear here; comparison remains deferred. config scopes
is supplied by suite assembly, not an acquisition operation.
package ckconfig
import (
"context"
"io"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type Selection struct {
Config commandkit.Supplied[string]
Overlay commandkit.Supplied[string]
}
type Operation string
const (
Validate Operation = "validate"
Show Operation = "show"
Sources Operation = "sources"
Explain Operation = "explain"
)
type Request struct {
Operation Operation
Selection Selection
}
type EffectiveWriter interface {
WriteEffective(io.Writer) error
}
type SourceReader interface {
Sources() []configkit.SourceInfo
}
type ExplanationReader interface {
Explain(configkit.Path) (configkit.Explanation, bool)
WriteExplanation(io.Writer, configkit.Path) error
}
type View struct {
Effective EffectiveWriter
Sources SourceReader
Explanation ExplanationReader
}
type Scope[I, S any] struct {
Name string
Summary string
Operations []Operation
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
// Adapter construction issues are retained in unexported representation.
}
type Selector[I any] struct {
Binding commandkit.Flag[I]
Scopes []string
}
type Suite[I, S any] struct {
NewInput func() I
NewState func() S
Selectors []Selector[I]
CheckInput func(scope string, selection Selection, input I) error
Scopes []Scope[I, S]
}
func FromConfigKit[I, S, T any](
name, summary string,
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
) Scope[I, S]
func WithScopeError[I, S any](scope Scope[I, S], err error) Scope[I, S]
func Command[I, S any](suite Suite[I, S]) commandkit.Command
func AppCommand[T any](
load func(context.Context, Selection) (*configkit.Result[T], error),
) commandkit.Command
The generic types are local to the suite, not to App. I contains typed CLI
values; S contains invocation state such as a cache. Neither belongs to the core
configuration model because the core has no such model. A scope with no operations
can omit Acquire and still be discoverable. A scope declaring operations needs
valid acquisition wiring. Providers supply the relevant native view facets and
validation diagnostics at invocation time, preserving current selected reports
and classifications rather than implementing a second configuration engine.
An empty selector scope list applies to all registered scopes; a nonempty list
identifies its applicable registered scopes. Unknown scope identifiers are
declaration errors. Bindings used by generated commands undergo core validation,
including standard-flag collisions; unused selector bindings follow the selected
absence/unused-binding rule above. Scope identifiers, capability selection,
defaults, and aggregate availability follow
ADR 0009. Non-applicable selectors are not
applied to a scope's input; this adds no new unused-selector rejection policy.
CheckInput receives the registered name of each requested scope alongside its
selection and initialized, filtered input before acquisition. It can distinguish
a selector omitted for an applicable scope from one intentionally not delivered
to another scope, and apply domain rules accordingly without loading configuration.
The definition suite retains its scope-free validation signature; it has no
configuration scope to supply.
FromConfigKit declares the operations supported by the selected ConfigKit
adapter and records the original loader, including an invalid nil loader.
Consumers may explicitly narrow Operations before calling Command; a
constructor issue is not erased by clearing the operation list. The suite factory
captures those lists and its generated neutral command tree immediately.
AppCommand uses an internal state-free suite with an app scope and a plain
application-configuration description. It neither exposes placeholder type
arguments nor demands a forwarding wrapper from the caller. A nil loader becomes
a retained config/app declaration error. The full suite is used for composition;
there is no incremental scope append operation after installation.
Retained scope errors
ckconfig.WithScopeError returns a scope retaining a non-nil construction error
alongside earlier causes, leaving the original scope unchanged. A nil error
preserves the scope. Retained causes preserve supported errors.Is/errors.As
identity across scope copies and narrowing of Operations. The family factory
collects those issues into its command declaration before capability selection
can discard them; no acquisition callback runs to discover a construction error.
ckprompt.ConfigScope uses this public operation to retain original loader errors
without accessing private ckconfig state. It otherwise declares the ordinary
scope fields and capabilities. The scope-error resolution
records the rationale and rejected alternative; the operation adds no new scope
installation lifecycle.
Selected PromptKit integration declarations
The ckprompt package uses ckconfig selections and scope contracts through its
selected module dependency. Definition selectors are plain typed flags; they do
not use a configuration-scope wrapper. Named prompt and profile inspection remain
the implemented subset; this pass adds no catalog operations.
package ckprompt
import (
"context"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
promptkit "gitea.maximumdirect.net/eric/promptkit"
)
type PromptRequest struct {
Selection ckconfig.Selection
ID string
Version commandkit.Supplied[string]
}
type ProfileRequest struct {
Selection ckconfig.Selection
ID string
}
type Suite[I, S any] struct {
NewInput func() I
NewState func() S
Selectors []commandkit.Flag[I]
CheckInput func(ckconfig.Selection, I) error
InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error)
InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error)
// Adapter construction issues are retained in unexported representation.
}
func ConfigScope[I, S any](
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
) ckconfig.Scope[I, S]
func FromEngine[I, S any](
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
) Suite[I, S]
func Command[I, S any](suite Suite[I, S]) commandkit.Command
ConfigScope declares the supported configuration facets for the promptkit
scope; FromEngine declares supported named inspection callbacks. Both leave
acquisition untouched until invocation and retain nil-loader errors. Each adapter
maps the owning library's public diagnostics into core-owned Diagnostic values.
Definition providers preserve diagnostics even when a named lookup fails.
Consumers may leave either definition callback nil to omit that capability.
A suite with both callbacks nil contributes no commands and ignores unused
selector bindings. It returns a no-op retaining any adapter-construction errors;
those errors still fail installation.
A nil loader supplied to FromEngine is invalid wiring, not missing capability;
setting its callbacks nil does not erase the retained error. For prompt lookup,
the adapter passes the requested version value to PromptKit, with omission using
its existing empty/default selection. The presence field remains available to a
custom inspection callback; this does not redefine upstream version semantics.
Input isolation and invocation lifetimes
Why inputs and shared state have separate types
Combining selector storage and a cache in one context type obscures their distinct
lifetimes and can cause scope-specific inputs to alias. Separate input values
could share a cache through references, but would leave input isolation and state
lifetime implicit. The selected Suite[I, S] makes the two responsibilities
explicit without requiring arbitrary application graphs to be cloned:
I: per-scope/per-command CLI input, initialized and populated independently.S: shared acquisition state for one selected inspection invocation.
There is no public clone hook and no reflection-based allocation of pointer inputs.
Two type parameters cost some declaration syntax in composed consumers, but make
sharing deliberate and lifetimes predictable. AppCommand hides both for the
simple case. This selected separation supports unified construction and
independently owned suites.
Factory timing
The execution rows describe the normal path after applicable conversion and preflight checks succeed and execution reaches the indicated stage. They do not require callbacks to run after a failure. An input-application or validation failure can stop processing before later scopes are initialized; shared state and acquisition are reached only after all requested input checks succeed. The no-call rows remain unconditional for CommandKit-owned paths.
| Path | NewInput |
Suite NewState |
Acquisition |
|---|---|---|---|
Construct declarations / New / Register |
Not called | Not called | Not called |
Help, version, config scopes |
Not called | Not called | Not called |
| Application command execution | Once for the selected command | No suite factory | Caller handler |
| Configuration inspection of one scope | Once for that scope | Once for the inspection invocation | Requested scope |
| Configuration inspection of several scopes | Once per selected scope | Once, shared by selected scopes | Requested scopes, in selected operation order |
| Named definition inspection | Once for the selected command | Once for that invocation | Selected inspection callback |
The factory counts describe application inputs and suite input/state. Built-in help may prepare its own private input through the common compiler; it still leaves the target application's and suites' factories, validation/execution callbacks, and providers uninvoked.
Input conversion and scope/capability preflight use metadata without acquisition. All supplied occurrences on an installed command undergo conversion, including selectors inapplicable to the requested scope and values supplied with help. Scope filtering affects value application and required-value checks, not conversion; an earlier malformed singleton occurrence remains an error even if a later one is valid.
For configuration execution, initialize and populate each requested scope input,
then run its CheckInput; initialize the shared state before acquisition after
input checks succeed. The input/state callbacks do not run for unselected scopes
or unrelated families. This explicitly separates the former mixed-context role:
NewState names the shared acquisition-state factory, distinct from
context.Context; it runs once when execution reaches that stage. NewInput
initializes scope storage independently. No shared-state callback is silently
called once per scope.
A missing factory supplies the zero value of its type. A nil pointer input/state
is permitted when the receiving binding callbacks and providers support it.
Applications requiring allocated pointer input supply NewInput; those requiring
a cache or other initialized state supply NewState. Factories that deliberately
return shared mutable input objects retain caller-owned effects; CommandKit does
not promise to repair those aliases. For scope-independent CLI storage, return
fresh input storage as demonstrated below. Pointer shared state is supported and
is shared deliberately, not copied.
Typed captured flag values are applied only to applicable inputs. Repeatable collections owned by CommandKit are independent when materialized into different inputs. Arbitrary objects supplied by factories, custom parsers, or closures are not deeply cloned. Providers can retain their input pointer according to their callback contract without a later scope overwriting it through a shared template. Subsequent invocations initialize new input/state; registration does not retain an invocation cache. No concurrent-dispatch guarantee follows from these rules.
Complete consumer registration examples
These examples target the selected APIs, not today's prototype. Each block is a complete consumer assembly function with imports and input declarations. Domain loaders and domain execution are supplied as typed function parameters where appropriate: their bodies are application behavior, not hidden registration helpers. No constructor error checks or provider acquisition are concealed in those parameters. The modules and declarations must be implemented before these blocks can compile against CommandKit itself.
1. Core only: typed commands and a composable group
package coreexample
import (
"fmt"
"strconv"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
type GreetInput struct {
Count commandkit.Supplied[int]
Name commandkit.Supplied[string]
Enabled commandkit.Supplied[bool]
}
func App() (*commandkit.App, error) {
countDefault := 1
nameDefault := "reader"
greet := commandkit.NewCommand(commandkit.CommandSpec[GreetInput]{
Name: "greet", Summary: "Write a greeting.",
Flags: []commandkit.Flag[GreetInput]{
commandkit.ValueFlag(
commandkit.FlagMeta{Name: "count", Usage: "Number of greetings."},
&countDefault, strconv.Atoi,
func(input *GreetInput, value commandkit.Supplied[int]) {
input.Count = value
},
),
commandkit.ValueFlag(
commandkit.FlagMeta{Name: "name", Usage: "Greeting recipient."},
&nameDefault, func(raw string) (string, error) { return raw, nil },
func(input *GreetInput, value commandkit.Supplied[string]) {
input.Name = value
},
),
commandkit.BoolFlag(
commandkit.FlagMeta{Name: "enabled", Usage: "Enable greeting output."},
true,
func(input *GreetInput, value commandkit.Supplied[bool]) {
input.Enabled = value
},
),
},
CheckInput: func(input GreetInput) error {
if input.Count.Value < 0 {
return fmt.Errorf("count must be nonnegative")
}
return nil
},
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
if !input.Enabled.Value {
return commandkit.Success()
}
for i := 0; i < input.Count.Value; i++ {
if _, err := fmt.Fprintf(invocation.Streams.Out, "Hello, %s!\n", input.Name.Value); err != nil {
return commandkit.Failure(err)
}
}
return commandkit.Success()
},
})
text := commandkit.Group("text", "Text utilities.", greet)
return commandkit.New(commandkit.Identity{Name: "Example", Version: "dev"}, text)
}
NewInput is omitted, so ordinary command execution starts with a zero-valued
GreetInput and applies defaults/presence through bindings. Explicit --count=0,
--name=, and --enabled=false remain supplied. Invalid command or binding
metadata is reported by the single final New call, not by greet or text.
Negative count is instead a usage result from invocation-time input validation.
No integration package or upstream configuration library is imported.
For a caller that wants to execute the assembled app, the common invocation is:
outcome := app.Execute(ctx, args, commandkit.Streams{Out: stdout, Err: stderr})
// The caller owns streams, cancellation, and final process exit/status mapping.
2. Context-free application configuration
package configexample
import (
"context"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type AppConfig struct {
Limit int `yaml:"limit"`
}
func App(
load func(context.Context, ckconfig.Selection) (*configkit.Result[AppConfig], error),
) (*commandkit.App, error) {
return commandkit.New(
commandkit.Identity{Name: "Example", Version: "dev"},
ckconfig.AppCommand(load),
)
}
The parameter is the complete loader signature. It can be an application's normal
ConfigKit acquisition function directly; no unused context argument, dummy suite,
or forwarding wrapper is required. load is not called during construction,
installation, help, or version. An explicitly empty configuration/overlay value
is available to the loader through Selection with Set == true.
3. Composed application: pointer inputs, shared cache, custom command
package composedexample
import (
"context"
"fmt"
"os"
"strconv"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
promptkit "gitea.maximumdirect.net/eric/promptkit"
)
type AppConfig struct {
Limit int `yaml:"limit"`
}
type InspectionInput struct {
Campaign commandkit.Supplied[string]
Limit commandkit.Supplied[int]
}
// Application-owned cache. Loaders may use Source for shared file reads.
// Source bytes are treated as read-only by this example's loaders.
type RunCache struct {
files map[string][]byte
}
func (cache *RunCache) Source(ctx context.Context, path string) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if contents, ok := cache.files[path]; ok {
return contents, nil
}
contents, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cache.files[path] = contents
return contents, nil
}
type RestoreInput struct {
ID commandkit.Supplied[string]
}
func App(
loadApp func(context.Context, ckconfig.Selection, *InspectionInput, *RunCache) (*configkit.Result[AppConfig], error),
loadEngine func(context.Context, ckconfig.Selection, *InspectionInput, *RunCache) (*promptkit.Engine, error),
restore func(context.Context, string) commandkit.Outcome,
) (*commandkit.App, error) {
parseString := func(raw string) (string, error) { return raw, nil }
campaignFlag := commandkit.ValueFlag[*InspectionInput, string](
commandkit.FlagMeta{Name: "campaign", Usage: "Select campaign inputs."},
nil, parseString,
func(input **InspectionInput, value commandkit.Supplied[string]) {
(*input).Campaign = value
},
)
limitFlag := commandkit.ValueFlag[*InspectionInput, int](
commandkit.FlagMeta{Name: "limit", Usage: "Override the application item limit."},
nil, strconv.Atoi,
func(input **InspectionInput, value commandkit.Supplied[int]) {
(*input).Limit = value
},
)
newInput := func() *InspectionInput { return &InspectionInput{} }
newState := func() *RunCache {
return &RunCache{files: make(map[string][]byte)}
}
appScope := ckconfig.FromConfigKit("app", "Application settings.", loadApp)
engineScope := ckprompt.ConfigScope(loadEngine)
configCommand := ckconfig.Command(ckconfig.Suite[*InspectionInput, *RunCache]{
NewInput: newInput, NewState: newState,
CheckInput: func(scope string, selection ckconfig.Selection, input *InspectionInput) error {
// Example application policy; it does not apply to engine inspection.
if scope == "app" && input.Campaign.Set && !input.Limit.Set {
return fmt.Errorf("application campaign inspection requires --limit")
}
return nil
},
Selectors: []ckconfig.Selector[*InspectionInput]{
{Binding: campaignFlag, Scopes: []string{"app", "promptkit"}},
{Binding: limitFlag, Scopes: []string{"app"}},
},
Scopes: []ckconfig.Scope[*InspectionInput, *RunCache]{appScope, engineScope},
})
definitions := ckprompt.FromEngine(loadEngine)
definitions.NewInput = newInput
definitions.NewState = newState
definitions.Selectors = []commandkit.Flag[*InspectionInput]{campaignFlag}
definitions.InspectPrompt = nil // Install profiles only; no prompt command.
definitionCommand := ckprompt.Command(definitions)
restoreCommand := commandkit.NewCommand(commandkit.CommandSpec[RestoreInput]{
Name: "restore", Summary: "Restore a session.",
Arguments: []commandkit.Argument[RestoreInput]{
commandkit.ValueArgument(
commandkit.ArgumentMeta{Name: "ID", Usage: "Session identifier."},
true, parseString,
func(input *RestoreInput, value commandkit.Supplied[string]) {
input.ID = value
},
),
},
Run: func(invocation commandkit.Invocation, input RestoreInput) commandkit.Outcome {
return restore(invocation.Context, input.ID.Value)
},
})
session := commandkit.Group("session", "Manage sessions.", restoreCommand)
// Domain callback validation also uses captured errors, without a helper error.
if restore == nil {
session = commandkit.WithError(session, fmt.Errorf("session restore: handler is nil"))
}
return commandkit.New(
commandkit.Identity{Name: "Example", Version: "dev"},
configCommand, definitionCommand, session,
)
}
Domain loaders are passed directly to the adapters, so a nil original loader cannot be hidden by a forwarding closure. Each signature shows both the isolated pointer input and shared cache. Loader bodies choose ConfigKit schemas and override application rules; CommandKit does not prescribe those application internals.
For example config show --scope all --campaign spring --limit=0:
- Capability preflight selects the
appandpromptkitscopes. newInputproduces two distinct pointers; both receiveCampaign.Set == trueandCampaign.Value == "spring".- Only the app input receives
Limit.Set == trueandLimit.Value == 0. The engine input retains its independently initialized, unsupplied limit. newStateproduces one*RunCache, passed to both acquisitions. If both loaders request the same path throughSource, its bytes are read once within that invocation. Sharing is application-owned, not a global CommandKit cache.- An app loader applies its limit override only when
input.Limit.Set; omission preserves its ordinary inherited configuration, while explicit zero overrides it.--campaign=likewise remains an explicitly empty campaign selection. - Native reports are presented under the existing
config showpreparation and delivery contract. No subsequent scope mutates the first input's selector fields.
For example promptkit show profile briefing --campaign spring, definition input
and state are initialized once for that invocation. Configuration scopes are not
acquired. The definition suite does not declare --limit, so that flag is not
accepted on the profile command. session restore invokes only the domain handler.
Help and scope discovery create neither cache nor pointer input.
The example's scope-aware validation permits
example config show --scope promptkit --campaign spring without --limit.
The same campaign selection for --scope app requires an explicitly supplied
limit, including zero. An aggregate request missing that limit fails input
validation before shared-state creation or either acquisition. This is the
example application's policy, not a new CommandKit selector requirement.
restore validation illustrates application wiring errors, not a requirement that
CommandKit inspect arbitrary closure internals. The application's declaration
factory records a known nil dependency because it wrapped that callback itself.
Independent installation and supported subsets
Using the declarations assembled above, each of these is a separate assembly choice, not another installation API:
// Configuration including engine scope, without named definition inspection:
app, err := commandkit.New(identity, configCommand, session)
// Named profile inspection, without any config family:
app, err := commandkit.New(identity, definitionCommand, session)
// Add a complete optional family later through the same atomic batch API:
err := app.Register(definitionCommand)
These are separate illustrative snippets; they are not successive redeclarations
in one function. Importing ckprompt without passing one of its declarations does
not install either surface. A metadata-only configuration scope can be declared
with a name/summary, an empty Operations list, and no Acquire; it remains
visible under config scopes. A full adapter's capabilities can also be narrowed:
appScope := ckconfig.FromConfigKit("app", "Application settings.", loadApp)
appScope.Operations = []ckconfig.Operation{ckconfig.Validate, ckconfig.Show}
The suite factory captures this selection and installs only supplied operations plus discovery. It does not invent comparison or catalog providers.
Focused design checks and expected outcomes
These checks specify observable target behavior, not a requirement to
add an exhaustive new test matrix. They use the declarations above; identity
has a valid name. Declaration errors are returned Go errors from installation,
not invocation status codes. Provider failures and ordinary input errors retain
the invocation model.
Retained errors are eager evidence, not delayed preparation
sentinel := errors.New("configuration declaration is incomplete")
declaration := commandkit.WithError(commandkit.NoCommand(), sentinel)
_, err := commandkit.New(identity, declaration)
// err != nil; errors.Is(err, sentinel) is true despite the absence marker.
This snippet uses standard-library errors. WithError needs no callback and
invokes no constructor later. A group's retained child errors and placement
metadata survive copying, wrapping, and installation in another app. Nil error
wrapping preserves the underlying declaration, including invalid zero commands.
Invalid wiring versus missing capability
var missingLoader func(context.Context, ckconfig.Selection) (*configkit.Result[AppConfig], error)
_, err := commandkit.New(identity, ckconfig.AppCommand(missingLoader))
// Installation fails with config/app loader context; no loader runs.
emptyDefinitions := ckprompt.Command(ckprompt.Suite[struct{}, struct{}]{})
app, err := commandkit.New(identity, emptyDefinitions)
// Success; no promptkit family appears in help.
An engine adapter created from a typed nil engine loader fails installation even if its inspection callbacks are later cleared. In contrast, a manually composed suite with one non-nil inspection provider and the other absent installs only the supplied capability. Metadata validation does not require every family operation. A valid provider that later returns a load/lookup error reports operational status 1 with supported public error identity and its captured diagnostics.
Collisions, placement, and per-call atomicity
existing := commandkit.Group("config", "Application-owned configuration tools.")
app, err := commandkit.New(identity, existing)
// Success while no standard config family is installed under the selected rule.
err = app.Register(ckconfig.AppCommand(load))
// Collision; the original domain group remains unchanged.
wrapper := commandkit.Group("tools", "Utilities.", ckconfig.AppCommand(load))
_, err = commandkit.New(identity, wrapper)
// Root-only placement error, not a new tools/config standard command path.
Installing the standard family first and then attempting a domain config group
also fails. Neither direction merges children or replaces handlers. Installing
Group("help", ...) or Group("version", ...) always collides with built-ins.
For app.Register(goodNewCommand, conflictingCommand), neither command is added
when the batch fails; commands installed by earlier successful calls remain.
A failed New returns no usable app. This is registry atomicity, not rollback
of arbitrary effects inside application callbacks.
Empty declarations
| Input | Expected installation result |
|---|---|
ckconfig.Command(ckconfig.Suite[struct{}, struct{}]{}) |
Error: no configuration scopes |
| One named scope with empty operations and nil acquisition | Success; config scopes describes it, no inspection verbs supplied by that scope |
| Definition suite with no callbacks and unused selector bindings | Explicit absence, unused bindings ignored; retained adapter errors still fail |
NoCommand() beside ordinary children |
Skipped, no placeholder help item |
WithError(NoCommand(), sentinel) |
Error retaining sentinel identity |
Zero Command{} at the root or as a child |
Declaration error |
RootGroup("config", ..., child) under an ordinary parent |
Placement error |
An empty definition no-op nested under Group("tools", ...) remains absence,
not a misplaced real family. A real empty RootGroup is still subject to placement
and collision checks. Configuration and definition factories choose the relevant
empty-family behavior before producing these declarations.
Capture and reuse
scope := ckconfig.FromConfigKit("app", "Application settings.", loadApp)
suite := ckconfig.Suite[*InspectionInput, *RunCache]{
NewInput: newInput, NewState: newState,
Selectors: []ckconfig.Selector[*InspectionInput]{
{Binding: campaignFlag, Scopes: []string{"app"}},
},
Scopes: []ckconfig.Scope[*InspectionInput, *RunCache]{scope},
}
declaration := ckconfig.Command(suite)
suite.Scopes[0].Summary = "Changed source metadata"
suite.Scopes[0].Operations = []ckconfig.Operation{ckconfig.Validate}
suite.Selectors[0].Scopes[0] = "another-scope"
first, firstErr := commandkit.New(identity, declaration)
second, secondErr := commandkit.New(identity, declaration)
// Both installations use the original captured scope, operation, and selector metadata.
The variables here have the types from the composed example. Original captured
operations remain available, the summary remains unchanged, and the selector
still applies to app; the later source edit is not revalidated as though it
were the returned command. Registry and invocation state are independent in
first and second. firstErr and secondErr are nil for otherwise valid wiring.
Callback references are shared intentionally, so mutable objects captured inside
those callbacks remain application-owned. Reusing a declaration does not authorize
concurrent execution or clone a callback's object graph.
Initialization and scope isolation
When its preflight and input checks succeed, the composed example's selected
aggregate inspection calls newInput twice and newState once. It delivers
distinct *InspectionInput addresses but the same
*RunCache to the two providers. Retaining the app input in a provider does not
expose it to later engine-selector writes. Selecting one scope calls each factory
once; selecting profile inspection initializes only its own invocation.
Without input selectors, a suite may deliberately use pointer I with no
NewInput and a loader accepting nil. With selectors whose accessors dereference
that pointer, it supplies the input factory, just as a pointer-shaped application
command does. Without NewState, pointer S is nil; providers that require a
cache supply the state factory. A fresh parsed-value store does not imply that
arbitrary application factories returning aliases are repaired or deep-copied.
Scope validation, flag identity, and error categories
| Situation | Expected result |
|---|---|
| Campaign selected for the example's app scope, with no limit | Scope-aware CheckInput returns a usage error; no shared state or acquisition |
| Same campaign selected for only the engine scope | Validation receives scope promptkit; the app-only limit rule does not apply |
| A required applicable flag was not supplied | ApplyTo returns an error matching ErrFlagInput; usage outcome |
| A required flag applies only to an unselected scope | Its requirement is not enforced for the selected scope |
| A copied original binding is selected from its captured values | Identity matches; values apply to the initialized input |
| A newly constructed lookalike binding is selected instead | It is absent from that captured set; error matches ErrFlagWiring |
The destination pointer passed to ApplyTo is nil |
Error matches ErrFlagWiring |
An ApplyTo error is wrapped with family/scope context |
errors.Is still identifies the category; wiring failures remain operational |
| Input validation fails before later scopes are initialized | Later input factories need not run; NewState and acquisitions do not run |
The flag-required checks here describe binding metadata, while the campaign/limit
relationship is the example's application validation rule. Neither requires
examining error-message text. ErrFlagInput and ErrFlagWiring classify errors
reported by flag application; they do not replace captured installation errors
or upstream provider errors.
Namespace reconciliation and implementation handoff
The user-selected lifetime is precise: help/version are always reserved; configuration and PromptKit standard roots are owned only when their real family is installed. An absent/no-op family reserves nothing. A domain group using an uninstalled integration name is permitted, but blocks later installation of the same root with a collision. Root-only placement is independent of that lifetime.
The documentation alignment is complete:
| Owner | Recorded decision |
|---|---|
| ADR 0011 | Captured declarations, atomic installation batches, installed-family ownership, root-only placement, separate input/shared-state lifetimes, and scope-aware configuration input validation |
| ADR 0001 status | Remains accepted, with refinements recorded in ADR 0011 and its decision text preserved |
| ADR 0010 | Core/integration dependency boundaries remain in effect; ADR 0011 refines registration and supersedes the earlier prototype migration-guide requirement |
| Architecture policy | Uniform installed-family ownership, optional integration facades, and declaration/input boundaries |
| Module roadmap | Final API and backend design selected; extraction and release adoption still pending |
| Backend design | Shared internal pipeline, runnable children, scope-error attachment, unused-binding validation, and both composition-question resolutions |
The staged implementation plan translates this finalized API, the finalized backend design, and the module roadmap into ordered implementation assignments. Both composition questions are resolved; the plan covers eager capture, error retention, neutral bindings, scope isolation, maintained examples, and release adoption. The documentation alignment neither implements these changes nor authorizes implementation by itself.