Document application-owned alias contracts

This commit is contained in:
2026-10-19 19:59:41 +00:00
parent 666c006a89
commit 333a630cf1
15 changed files with 270 additions and 64 deletions

16
app.go
View File

@@ -33,12 +33,18 @@ type commandNode struct {
command Command
}
// New creates an application with the supplied identity and initial
// declarations.
// New creates an application with the supplied identity and initial canonical
// commands and routing rules. Use []Declaration to expand a mixed declaration
// collection; []Command cannot be expanded as ...Declaration without
// converting its elements.
//
// The identity name must not be empty. New installs its generated help and
// version commands and caller declarations as one batch; an invalid declaration
// returns an error without exposing an App. The root help and version names are
// reserved for generated commands.
// version commands and caller declarations as one batch; an invalid, nil, or
// zero declaration returns an error without exposing an App. Factory issues
// retained in Command and RoutingRule values are reported during installation.
// Identical duplicate routing mappings are accepted; conflicting mappings
// fail the batch. The root help and version names are reserved for generated
// commands.
func New(identity Identity, declarations ...Declaration) (*App, error) {
if identity.Name == "" {
return nil, fmt.Errorf("application identity name must not be empty")

View File

@@ -10,21 +10,32 @@ import (
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
// Command is an opaque, reusable declaration captured by NewCommand, Group,
// RootGroup, or an integration. Capture copies declaration slices but retains
// callback and caller-owned object references. Installation validates the
// declaration before changing an App registry.
// Command is an opaque, reusable declaration of canonical commands and groups.
// NewCommand, Group, RootGroup, and integration command factories return
// Command values. Capture copies declaration slices but retains callback and
// caller-owned object references. Installation validates the declaration
// before changing an App registry.
type Command struct{ record *commandRecord }
// Declaration is a captured command or application routing rule accepted by
// New and App.Register. Only CommandKit declaration types implement it.
// Declaration is a captured canonical command or application routing rule
// accepted by New and App.Register. Use []Declaration when collecting both
// kinds. Group and RootGroup children remain Commands; install routing rules
// alongside the completed command tree through New or Register. Only
// CommandKit declaration types implement Declaration.
//
// A zero Command, zero RoutingRule, nil declaration, or typed nil declaration
// pointer is invalid. Use NoCommand to represent an intentionally absent
// optional command.
type Declaration interface {
commandkitDeclaration()
}
func (Command) commandkitDeclaration() {}
// RoutingRule is an opaque, reusable command or flag alias declaration.
// RoutingRule is an opaque, reusable command or flag alias declaration. Alias,
// FlagAlias, and FlagAliasFor capture routing rules eagerly and retain any
// construction issues for New or App.Register to report. Identical duplicate
// mappings are accepted; competing targets for the same source are errors.
type RoutingRule struct{ record *routingRuleRecord }
func (RoutingRule) commandkitDeclaration() {}
@@ -236,9 +247,11 @@ func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, operands
}, nil
}
// NewCommand captures a typed command declaration without constructing its
// input or running callbacks. Construction issues are retained and reported
// when the declaration is installed. The returned declaration can be reused.
// NewCommand captures a typed canonical command declaration without
// constructing its input or running callbacks. It copies the spec's flags,
// arguments, and children slices. Construction issues are retained and
// reported when the declaration is installed; NewCommand itself does not
// return an error. The returned declaration can be reused across Apps.
func NewCommand[I any](spec CommandSpec[I]) Command {
return captureCommand(spec, nil)
}
@@ -260,25 +273,29 @@ func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution
return Command{record: record}
}
// Group captures a non-runnable command group and a copy of its relative child
// slice. Child declarations remain reusable and are not recursively copied.
// Group captures a non-runnable canonical command group and a copy of its
// relative Command child slice. Child declarations remain reusable and are not
// recursively copied. Routing rules cannot be children; install them alongside
// the root command tree through New or App.Register.
func Group(name, summary string, children ...Command) Command {
record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
captureComposition(record)
return Command{record: record}
}
// RootGroup captures a group that may only be installed at the application root.
// Installation rejects it below any other command, including through wrappers.
// RootGroup captures a group that may only be installed at the application
// root. Installation rejects it below any other command, including through
// wrappers. Its children are canonical Commands; install routing rules at the
// application root separately.
func RootGroup(name, summary string, children ...Command) Command {
command := Group(name, summary, children...)
command.record.rootOnly = true
return command
}
// WithError returns a new declaration that also retains a non-nil err for
// installation. Earlier declaration and child errors remain available, and
// wrapped error identity is preserved. A nil err returns command unchanged.
// WithError returns a new Command declaration that also retains a non-nil err
// for installation. Earlier declaration and child errors remain available,
// and wrapped error identity is preserved. A nil err returns command unchanged.
func WithError(command Command, err error) Command {
if err == nil {
return command
@@ -294,14 +311,17 @@ func WithError(command Command, err error) Command {
return Command{record: &record}
}
// NoCommand explicitly represents an absent optional declaration. Installation
// skips it, but reports errors attached to it; a zero Command is invalid and is
// not an absence marker.
// NoCommand explicitly represents an absent optional command declaration.
// Installation skips it, but reports errors attached to it; a zero Command is
// invalid and is not an absence marker. Routing rules have no absence marker.
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
// Alias captures a command-path alias from path to target. Paths are
// whitespace-separated sequences of command names; construction issues are
// retained for New or App.Register to report.
// whitespace-separated sequences of command names relative to the application
// root. The target must resolve to a canonical command or group; aliases do not
// add operands or transform flag values. Construction issues are retained for
// New or App.Register to report. Identical mappings are accepted, while
// different targets for the same source path are installation errors.
func Alias(path, target string) RoutingRule {
record := &routingRuleRecord{
kind: commandAliasRule,
@@ -314,7 +334,10 @@ func Alias(path, target string) RoutingRule {
}
// FlagAlias captures an application-wide flag spelling alias. Spellings must
// be one long flag name or one individual short spelling.
// be one long flag name or one individual short spelling. The mapping applies
// wherever its canonical target flag is available and contributes nothing on
// commands without that flag. Identical mappings are accepted; different
// targets for the same source spelling in this scope are installation errors.
func FlagAlias(spelling, target string) RoutingRule {
record := &routingRuleRecord{
kind: globalFlagAliasRule,
@@ -327,7 +350,10 @@ func FlagAlias(spelling, target string) RoutingRule {
}
// FlagAliasFor captures a flag spelling alias scoped to a canonical command
// path.
// path, not an alias path. The command and canonical target flag must be
// available in the complete installation. Scoped mappings take precedence over
// application-wide mappings with the same source spelling. Identical mappings
// are accepted; competing targets in the same scope are installation errors.
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
record := &routingRuleRecord{
kind: scopedFlagAliasRule,

6
doc.go
View File

@@ -1,5 +1,7 @@
// Package commandkit provides the core instance-owned CLI dispatcher, typed
// command declarations, input bindings, and invocation outcomes. Optional
// integration modules provide ConfigKit and PromptKit-specific declarations;
// command declarations, application-owned command and flag aliases, input
// bindings, and invocation outcomes. New and App.Register accept canonical
// Command declarations and RoutingRule declarations together. Optional
// integration modules provide ConfigKit and PromptKit-specific commands;
// importing those modules does not install their commands.
package commandkit

View File

@@ -83,6 +83,74 @@ Typed inputs keep supplied state separate from defaults. An explicit empty,
false, or zero value is supplied. Help and version are available without
constructing application command inputs.
## Application-owned aliases
Applications can install command and flag aliases alongside their canonical
commands. Aliases route an invocation to existing behavior; they do not add
handlers, commands, or flags to a canonical command family.
Command alias paths are sequences of command tokens relative to the application
root. The executable name, flags, flag values, and operands are not part of an
alias path. Matching uses complete tokens and chooses the longest matching
path. A group alias preserves its remaining command tokens, and aliases can
compose through chains. For example, `list collections` can route to
`promptkit list collections`, while `pk` can route to the `promptkit` group.
The target must resolve to a canonical command or group.
Aliases do not rewrite operands or supply arguments. In particular,
`promptkit test KIND` remains one canonical command whose `KIND` is an operand;
`all` is an operand value, not a child command. `smoke → promptkit test` is a
valid shortcut because it preserves the caller's `KIND` value.
Flag aliases can be application-wide or scoped to a canonical command path.
An application-wide alias is effective wherever its canonical target flag is
available and is ignored elsewhere. A scoped alias requires its canonical
command and target flag to be installed. The selected canonical command
determines applicable scoped aliases, so the alias also works when that
command is reached through another command path. A scoped mapping takes
precedence over an application-wide mapping with the same spelling.
Only tokens parsed as flag names participate in flag aliasing. Values, operands,
and tokens following `--` remain unchanged. This includes values that look like
flags, such as `--message=--hlep`. Aliased flags use the target's canonical
binding, conversion, default, required-value check, presence, and repetition
behavior. Mixed spellings preserve occurrence order: singleton flags use the
last successfully parsed occurrence, every supplied occurrence is converted,
and repeatable flags accumulate values in order.
Flag aliases accept one long spelling or one individual short spelling. Short
aliases use the canonical short-option cluster and attached-value grammar:
| Input | Interpretation |
| --- | --- |
| `-vq` | Two boolean flags, `--verbose` and `--quiet`. |
| `-cblue` or `-c blue` | Value `blue` for a value-taking `-c` flag. |
| `-vqcblue` | `-v`, `-q`, then `--color=blue`. |
| `-cvq` | Value `vq` for a value-taking `-c` flag. |
| `-v=false` | Explicit false for the boolean flag `-v`. |
| `-c=` | An explicitly supplied empty string. |
Boolean flags use presence as true; an explicit boolean value requires `=`.
The first value-taking short flag consumes the remaining cluster suffix or the
next argument. A consumed value is never interpreted as an alias. The canonical
short-option empty-value form `-c=` supplies an empty value, consistently with
`--color=` and `-c ""`.
Help reached through an alias describes the canonical command's flags and
operands while using the entered route in usage. It identifies the canonical
target, lists applicable aliases separately, and supports global help aliases
such as `--hlep → --help`. Help for an alias-only prefix lists its routes
without creating a command group; the bare incomplete prefix points to that
help. Help and alias registration do not construct command inputs or invoke
acquisition callbacks.
Usage diagnostics preserve the command path and flag spelling the caller used.
When a command alias resolved successfully, usage identifies its canonical
target while retaining the entered path in the diagnostic. An unresolved route
receives no invented canonical label. A flag conversion error identifies the
entered flag spelling and the failing occurrence while preserving the original
cause.
## Configuration scopes
An application installs the declaration returned by

View File

@@ -1,9 +1,11 @@
# Consumer guide
CommandKit is distributed as three Go modules. Start with the core and add an
integration module only when the application uses that library. Each module
returns core `commandkit.Command` declarations, which the application installs
through `commandkit.New` or `App.Register`.
integration module only when the application uses that library. Core and
integration command factories return canonical `commandkit.Command` values.
Applications may install those alongside application-owned `RoutingRule`
declarations through `commandkit.New` or `App.Register`; routing rules are
installed at the application root, not as group children.
## Module selection
@@ -83,6 +85,45 @@ values convert before input construction. A command's `NewInput` creates input
only when execution reaches it; help does not construct caller input or run
provider callbacks.
## Application-owned aliases
Pass commands and aliases together as declarations. Integration factories can
be called directly in the installation list, and command-scoped flag aliases
name the canonical command path:
```go
app, err := commandkit.New(
identity,
ckprompt.Command(suite),
commandkit.Alias("pk", "promptkit"),
commandkit.FlagAliasFor("promptkit show prompt", "--fmt", "--format"),
)
```
For a reusable collection, use `[]commandkit.Declaration`. Go does not expand a
`[]commandkit.Command` into `...commandkit.Declaration`, so convert command-only
slices when composing them with routing rules:
```go
commands := []commandkit.Command{ckprompt.Command(suite)}
declarations := make([]commandkit.Declaration, 0, len(commands)+1)
for _, command := range commands {
declarations = append(declarations, command)
}
declarations = append(declarations, commandkit.Alias("pk", "promptkit"))
app, err := commandkit.New(identity, declarations...)
```
New and Register validate each supplied batch before publishing its commands
and routing rules. Factories capture their inputs eagerly; malformed or
conflicting rules are reported at installation. Identical rules are safe to
contribute from reusable helpers. The [core example](../../examples/core) and
[composed consumer](../../integrations/ckprompt/examples/composed) show complete
maintained wiring with ordinary command behavior alongside aliases. The
[application-owned aliases roadmap](../roadmap/aliases.md) records the routing
contract; the [CLI reference](../cli.md#application-owned-aliases) describes
operator-visible behavior.
## ConfigKit scopes
For an application with one ordinary app configuration loader,

View File

@@ -11,6 +11,12 @@ The [README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners.
While application-owned alias work is active, use its
[feature roadmap](roadmap/aliases.md), [implementation sequence](roadmap/implementation.md),
and accepted [ADR 0013](adr/0013-application-alias-routing.md) for scope,
progress, and architectural decisions. Keep deferred capabilities in
[deferred work](roadmap/deferred.md).
## Initial orientation
1. Inspect the worktree and preserve unrelated staged and unstaged changes.
@@ -47,6 +53,7 @@ canonical documentation owners.
| Task | Read |
| --- | --- |
| Implemented CLI behavior | [CLI reference](cli.md) |
| Application-owned aliases | [Feature roadmap](roadmap/aliases.md), [implementation plan](roadmap/implementation.md), and [ADR 0013](adr/0013-application-alias-routing.md) while this work is active |
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs |
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md), refined by [ADR 0011](adr/0011-captured-declarations-and-installation.md) |
| Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-cobra-backend.md) |

View File

@@ -8,18 +8,30 @@ this page inventories the implemented owners.
## Root `commandkit`
The root facade owns captured command declarations, binding conversion,
invocation coordination, the per-App registry, standard help/version commands,
and outcome/diagnostic delivery. Its dependency graph has no ConfigKit or
PromptKit integration dependency. Applications provide typed callbacks and
choose when to install declarations.
The root facade owns captured canonical command declarations, application-owned
routing rules, binding conversion, invocation coordination, the per-App
registries, standard help/version commands, and outcome/diagnostic delivery.
Its dependency graph has no ConfigKit or PromptKit integration dependency.
Applications provide typed callbacks and install routing rules alongside the
complete canonical command tree through the same batch API.
The shared command compiler prepares supplied values before constructing input.
The batch installer validates retained issues, placement, and collisions before
mutating the registry. Each invocation uses one routing snapshot and backend
session; a successful registration invalidates the snapshot for a later
invocation. The root package owns app input and callback execution, not provider
configuration semantics.
Canonical commands own handlers and parsing/help metadata; routing rules resolve
entered command paths and flag spellings to those canonical records without
adding command nodes or handlers. The shared command compiler prepares supplied
values before constructing input. The batch installer validates retained
issues, placement, and collisions across both registries before publishing
either. Each invocation uses one routing snapshot and backend session; a
successful registration invalidates the snapshot for a later invocation. The
root package owns app input and callback execution, not provider configuration
semantics.
## `internal/alias` and `internal/commandpath`
`internal/commandpath` provides shared path parsing and token operations used by
declaration capture, routing, and backend selection. `internal/alias` compiles
application routing rules, resolves command prefixes while retaining token
provenance, and validates command-route termination. Neither package owns
handlers, application factories, or backend types.
## `internal/commandmeta`

View File

@@ -83,18 +83,24 @@ describes the outcome and delivery model.
Use explicit instance construction and composable command registration, not
package-global or import-time registries. Applications and integrations supply
the same captured command declarations through the core installation API.
captured canonical command declarations through the core installation API.
Declaration factories capture metadata, bindings, and callback references eagerly;
construction errors remain attached for installation to report. Capture the
metadata collections needed to keep later edits to a source declaration from
changing a returned command. Application callback objects retain their ownership.
One definition supplies routing, parsing, and help metadata.
Canonical command declarations own handlers, parsing metadata, and help
metadata. Applications may provide separate routing rules in the same
installation batch; these rules resolve invocations to canonical commands and
do not add command nodes, handlers, or flags.
Validate each installation batch before mutating the instance registry. A failed
construction yields no usable application; a failed later registration leaves the
existing registry intact. Separate successful registration calls are not one
transaction. Duplicate paths and attempts to replace reserved commands report
errors rather than letting registration order select a winner.
transaction. Duplicate canonical paths and attempts to replace reserved commands
report errors rather than letting registration order select a winner. Identical
routing rules are idempotent; competing targets for the same source and scope
report an error. Command-scoped flag routing refers to an exact canonical
command path.
Explicit absent-command declarations contribute no commands, help entries, or
namespace claims; attached errors are reported before skipping absence. An invalid

View File

@@ -694,6 +694,15 @@ all newly documented alias commands also ran successfully.
canonical owner; no future alias APIs are described as existing before their
implementation is present. Prose checks and relevant examples agree.
**Completed:** GoDoc now distinguishes canonical `Command` declarations from
root-installed routing rules and documents captured errors, invalid nil/zero
values, idempotent duplicates, canonical flag scopes, and invocation paths. The
CLI reference, consumer guide, maintained example READMEs, internal overview,
architecture policy, and active developer navigation describe the implemented
contracts and integration wiring. Local Markdown links and fragments resolved;
`git diff --check -- '*.md' COPYRIGHT` and the cached equivalent passed. The
deferred-work record and accepted ADR were left unchanged.
## Stage 16: Record compatibility impact for the next core release
**Work**

View File

@@ -16,6 +16,15 @@ alias. Its declaration slice mixes the canonical group and routing rules as
`Invocation.CanonicalPath()` so the alternate route and its canonical target
are visible in the output.
Integration factories also return `commandkit.Command` and can be passed
directly to `New` beside `commandkit.Alias` or flag-alias declarations. For a
command-only `[]commandkit.Command`, convert its elements into a
`[]commandkit.Declaration` before appending routing rules; Go cannot expand the
command slice as `...commandkit.Declaration`. The
[consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
shows both forms, and the [composed consumer](../../integrations/ckprompt/examples/composed)
is the maintained integration example.
These aliases use the current local CommandKit source. Published CommandKit
v0.2.0 does not include alias declarations; consumers can use them after the
next core release. Repository contributors can run its test with

View File

@@ -28,7 +28,9 @@ type Streams struct {
}
// Invocation provides execution context and output streams to a command
// handler.
// handler. For command aliases, EnteredPath and CanonicalPath expose the route
// the caller used and the selected canonical command path. Both omit the
// executable name, flags, and operands.
type Invocation struct {
// Context carries caller cancellation and request-scoped values.
Context context.Context
@@ -39,10 +41,13 @@ type Invocation struct {
canonicalPath string
}
// EnteredPath returns the command path used to invoke the selected command.
// It is empty for an Invocation that was not created by command execution.
// EnteredPath returns the command path used to invoke the selected command,
// excluding the executable name, flags, and operands. It is empty for an
// Invocation that was not created by command execution.
func (inv Invocation) EnteredPath() string { return inv.enteredPath }
// CanonicalPath returns the selected command's canonical path.
// It is empty for an Invocation that was not created by command execution.
// CanonicalPath returns the selected command's canonical path after resolving
// aliases, excluding the executable name, flags, and operands. It equals
// EnteredPath for a direct canonical invocation and is empty for an Invocation
// that was not created by command execution.
func (inv Invocation) CanonicalPath() string { return inv.canonicalPath }

View File

@@ -1,6 +1,7 @@
// Package ckconfig provides CommandKit's optional ConfigKit integration. Its
// declarations install the standard config command family through the core
// commandkit package. Source reports preserve ConfigKit's destination,
// placement, and version-check evidence; importing ckconfig alone installs no
// commands.
// canonical Command declaration installs the standard config command family
// through the core commandkit package. Applications can install CommandKit
// RoutingRule declarations alongside it through commandkit.New or
// App.Register. Source reports preserve ConfigKit's destination, placement,
// and version-check evidence; importing ckconfig alone installs no commands.
package ckconfig

View File

@@ -1,5 +1,8 @@
// Package ckprompt provides optional PromptKit configuration comparison,
// prompt catalogs, named inspections, requirements discovery and validation,
// and native smoke testing for CommandKit. Importing ckprompt installs neither
// family; callers compose its declarations with core and ckconfig explicitly.
// and native smoke testing for CommandKit. Its command factories return
// canonical commandkit.Command declarations. Applications can install
// commandkit.RoutingRule aliases alongside them through commandkit.New or
// App.Register. Importing ckprompt installs neither family; callers compose its
// declarations with core and ckconfig explicitly.
package ckprompt

View File

@@ -40,6 +40,14 @@ shortens the PromptKit group. `--fmt` is scoped to named prompt inspection and
`--hlep` is available globally. The `smoke` shortcut retains the canonical
`promptkit test KIND` operand, including the special `all` aggregate.
The composition calls `ckconfig.Command(suite)` and `ckprompt.Command(definitions)`
directly in a `[]commandkit.Declaration` with root-installed routing rules.
Group children remain canonical `Command` values. For command-only slices,
convert each `Command` to `Declaration` before appending aliases. The
[consumer guide](../../../../docs/consumers/guide.md#application-owned-aliases)
includes minimal direct-call and slice-conversion examples; this README documents
the maintained complete consumer.
The sources and session store are small in-memory fixtures. `--campaign` is a
typed selector shared by the app and PromptKit configuration scopes. Their
`ckconfig.Suite[*inspectionInput, *runCache]` uses `NewInput` to initialize each

View File

@@ -9,10 +9,13 @@ import (
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
// Register adds a batch of declarations to the application. The complete batch
// is validated before registry mutation; on error the registry and its derived
// command metadata remain unchanged. A successful nonempty batch becomes
// visible to later invocations. Declarations remain reusable.
// Register adds a batch of canonical commands and routing rules to the
// application. The complete batch is validated before registry mutation; on
// error the registry and its derived command metadata remain unchanged. A
// successful nonempty batch becomes visible to later invocations. Declarations
// remain reusable. Identical duplicate routing mappings are accepted, while
// competing targets for the same source are errors. Nil and zero declarations
// are invalid; use NoCommand for an intentionally absent optional command.
func (a *App) Register(declarations ...Declaration) error {
return a.installBatch(declarations...)
}