Files
commandkit/docs/roadmap/registration-api.md

53 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. 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; 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 invocation input/context factories. They 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 validate or retain wiring errors before representing an empty family as a no-op; absence must not swallow errors. A no-op does not reserve a namespace.

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
	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.

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 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 identifiers and reserved standard-flag collisions are declaration errors. 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.

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 valid suite with both nil is an explicit no-op after eager declaration validation. 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 fields and a cache in one context type obscures their lifetimes. A shared pointer to that object cannot isolate selector fields between scopes. Cloning arbitrary application graphs would add a promise CommandKit cannot make. The selected Suite[I, S] makes the actual two responsibilities explicit:

  • 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

Input conversion and scope/capability preflight use metadata without acquisition. 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:

  1. Capability preflight selects the app and promptkit scopes.
  2. newInput produces two distinct pointers; both receive Campaign.Set == true and Campaign.Value == "spring".
  3. Only the app input receives Limit.Set == true and Limit.Value == 0. The engine input retains its independently initialized, unsupplied limit.
  4. newState produces one *RunCache, passed to both acquisitions. If both loaders request the same path through Source, its bytes are read once within that invocation. Sharing is application-owned, not a global CommandKit cache.
  5. 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.
  6. Native reports are presented under the existing config show preparation 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
Valid definition suite with no callbacks Explicit absence, no namespace claimed
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, and separate input/shared-state lifetimes
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 migration-guide requirement for this unreleased prototype
Architecture policy Uniform installed-family ownership, optional integration facades, and declaration/input boundaries
Module roadmap Final API selected; documentation alignment completed; extraction and release adoption still pending

A staged module implementation plan remains to be written from this finalized API and the module roadmap. It should specify 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.