Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changesets/1792673500-0907a518.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1792673500-0907a518
features:
- cliCommands
targets:
- cli
type: feat
bump: minor
description: declare terminal failure states with output.outcome so a command exits 1 when a successful response reports a declared failure state, for discrete JSON responses (statePointer) and streamed events (streamStatePointer), with the failure detail surfaced from errorPointer / streamErrorPointer and reported as CLI_OUTCOME_FAILED
author: AshGodfrey
date: "2026-10-22"
172 changes: 152 additions & 20 deletions internal/extensions/cli_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,30 @@ type CLICommandOutput struct {
Projection *CLICommandProjection `json:"projection" yaml:"projection"`
Artifact *CLICommandArtifact `json:"artifact,omitempty" yaml:"artifact,omitempty"`
Stream *CLICommandStreamProjection `json:"stream,omitempty" yaml:"stream,omitempty"`
Outcome *CLICommandOutcome `json:"outcome,omitempty" yaml:"outcome,omitempty"`
}

// CLICommandOutcome classifies the terminal state carried inside a
// successful response so the generated command can fail (exit 1) when the
// API reports failure with an HTTP success code. StatePointer addresses the
// state in the discrete JSON response and StreamStatePointer the same state
// inside streamed events; at least one is declared. States is keyed by the
// API's enum value, exactly as async.states, and only the "failure"
// classification changes runtime behavior. ErrorPointer optionally addresses
// the failure detail (an object or an array of objects) whose
// ErrorMessageField string is appended to the failure message. A
// single-route intent without its own table inherits its operation entry's.
type CLICommandOutcome struct {
StateFrom string `json:"stateFrom,omitempty" yaml:"stateFrom,omitempty"`
StatePointer string `json:"statePointer,omitempty" yaml:"statePointer,omitempty"`
StreamStateFrom string `json:"streamStateFrom,omitempty" yaml:"streamStateFrom,omitempty"`
StreamStatePointer string `json:"streamStatePointer,omitempty" yaml:"streamStatePointer,omitempty"`
States map[string]string `json:"states" yaml:"states"`
ErrorFrom string `json:"errorFrom,omitempty" yaml:"errorFrom,omitempty"`
ErrorPointer string `json:"errorPointer,omitempty" yaml:"errorPointer,omitempty"`
StreamErrorFrom string `json:"streamErrorFrom,omitempty" yaml:"streamErrorFrom,omitempty"`
StreamErrorPointer string `json:"streamErrorPointer,omitempty" yaml:"streamErrorPointer,omitempty"`
ErrorMessageField string `json:"errorMessageField,omitempty" yaml:"errorMessageField,omitempty"`
}

// CLIOperation augments the generated command for one operation. Unlike an
Expand Down Expand Up @@ -506,6 +530,7 @@ var CLIRuntimeHintReasons = []string{
"CLI_ASYNC_FAILED",
"CLI_ASYNC_TIMEOUT",
"CLI_ASYNC_UNKNOWN_STATE",
"CLI_OUTCOME_FAILED",
}

// cliReservedCommandKeys are keys reserved for future capabilities. Each maps
Expand All @@ -524,7 +549,9 @@ var cliCommandKeys = []string{
"args", "flags", "preset", "hints", "examples", "help", "jq", "output", "async",
}

var cliOutputKeys = []string{"artifact", "stream"}
var cliOutputKeys = []string{"artifact", "stream", "outcome"}

var cliOutcomeKeys = []string{"statePointer", "streamStatePointer", "states", "errorPointer", "streamErrorPointer", "errorMessageField"}

var cliOutputStreamKeys = []string{"select", "metadata"}

Expand Down Expand Up @@ -779,17 +806,31 @@ func (d *cliManifestDecoder) decodeOperation(opID string, node *yaml.Node) (*CLI
return nil, err
}
if len(outputEntries) == 0 {
return nil, fmt.Errorf("line %d: operation %q output requires stream", entry.Value.Line, opID)
return nil, fmt.Errorf("line %d: operation %q output requires stream or outcome", entry.Value.Line, opID)
}
for _, outputEntry := range outputEntries {
if outputEntry.Key.Value != "stream" {
return nil, fmt.Errorf("line %d: operation %q output has unknown key %q%s", outputEntry.Key.Line, opID, outputEntry.Key.Value, cliDidYouMean(outputEntry.Key.Value, []string{"stream"}))
}
stream, err := d.decodeStreamProjection("operation "+opID, outputEntry.Value)
if err != nil {
return nil, err
switch outputEntry.Key.Value {
case "stream":
stream, err := d.decodeStreamProjection("operation "+opID, outputEntry.Value)
if err != nil {
return nil, err
}
if op.Output == nil {
op.Output = &CLICommandOutput{}
}
op.Output.Stream = stream
case "outcome":
outcome, err := d.decodeOutcome("operation "+opID, outputEntry.Value)
if err != nil {
return nil, err
}
if op.Output == nil {
op.Output = &CLICommandOutput{}
}
op.Output.Outcome = outcome
default:
return nil, fmt.Errorf("line %d: operation %q output has unknown key %q%s", outputEntry.Key.Line, opID, outputEntry.Key.Value, cliDidYouMean(outputEntry.Key.Value, []string{"stream", "outcome"}))
}
op.Output = &CLICommandOutput{Stream: stream}
}
case "flags":
flags, err := d.decodeInputs("operation "+opID, entry.Value, false)
Expand Down Expand Up @@ -947,7 +988,7 @@ func (d *cliManifestDecoder) decodeCommand(key string, path []string, id string,
}

if outputNode != nil {
artifact, stream, err := d.decodeOutput(key, outputNode)
artifact, stream, outcome, err := d.decodeOutput(key, outputNode)
if err != nil {
return nil, err
}
Expand All @@ -956,6 +997,7 @@ func (d *cliManifestDecoder) decodeCommand(key string, path []string, id string,
}
cmd.Output.Artifact = artifact
cmd.Output.Stream = stream
cmd.Output.Outcome = outcome
}
if cmd.Output != nil && cmd.Output.Projection != nil && cmd.Output.Artifact != nil {
return nil, fmt.Errorf("command %q declares both jq and output.artifact; the artifact declaration owns the command's default output (users can still project with --jq at runtime)", key)
Expand All @@ -970,6 +1012,9 @@ func (d *cliManifestDecoder) decodeCommand(key string, path []string, id string,
if cmd.Output != nil && cmd.Output.Stream != nil {
return nil, fmt.Errorf("command %q declares both async and output.stream; async polling requires discrete JSON responses and cannot be combined with streamed output", key)
}
if cmd.Output != nil && cmd.Output.Outcome != nil {
return nil, fmt.Errorf("command %q declares both async and output.outcome; async.states already classifies the terminal poll state", key)
}
}

if err := d.decodeSource(cmd, key, node, opNode, routesNode, plannedNode, customNode, groupNode, sourceNode); err != nil {
Expand Down Expand Up @@ -998,7 +1043,7 @@ func (d *cliManifestDecoder) decodeCommand(key string, path []string, id string,
if cmd.Output != nil && cmd.Output.Projection != nil {
illegal = append(illegal, "jq")
}
if cmd.Output != nil && (cmd.Output.Artifact != nil || cmd.Output.Stream != nil) {
if cmd.Output != nil && (cmd.Output.Artifact != nil || cmd.Output.Stream != nil || cmd.Output.Outcome != nil) {
illegal = append(illegal, "output")
}
if asyncNode != nil {
Expand Down Expand Up @@ -1330,38 +1375,125 @@ func cliDecodeBackoff(node *yaml.Node) (float64, error) {
// capabilities beyond the command-level jq sugar: artifact (the result is a
// written media file) and stream (the selected field of each streamed event
// is written raw to stdout as it arrives).
func (d *cliManifestDecoder) decodeOutput(cmdKey string, node *yaml.Node) (*CLICommandArtifact, *CLICommandStreamProjection, error) {
func (d *cliManifestDecoder) decodeOutput(cmdKey string, node *yaml.Node) (*CLICommandArtifact, *CLICommandStreamProjection, *CLICommandOutcome, error) {
entries, err := cliMapEntries(node, fmt.Sprintf("command %q output", cmdKey))
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}

var (
artifact *CLICommandArtifact
stream *CLICommandStreamProjection
outcome *CLICommandOutcome
)
for _, entry := range entries {
switch entry.Key.Value {
case "artifact":
artifact, err = d.decodeArtifact(cmdKey, entry.Value)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
case "stream":
stream, err = d.decodeStreamProjection(cmdKey, entry.Value)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
case "outcome":
outcome, err = d.decodeOutcome(fmt.Sprintf("command %q", cmdKey), entry.Value)
if err != nil {
return nil, nil, nil, err
}
case "jq":
return nil, nil, fmt.Errorf("line %d: command %q: jq is declared at the command level (jq: <expression>), not under output", entry.Key.Line, cmdKey)
return nil, nil, nil, fmt.Errorf("line %d: command %q: jq is declared at the command level (jq: <expression>), not under output", entry.Key.Line, cmdKey)
default:
return nil, nil, fmt.Errorf("line %d: command %q output has unknown key %q%s", entry.Key.Line, cmdKey, entry.Key.Value, cliDidYouMean(entry.Key.Value, cliOutputKeys))
return nil, nil, nil, fmt.Errorf("line %d: command %q output has unknown key %q%s", entry.Key.Line, cmdKey, entry.Key.Value, cliDidYouMean(entry.Key.Value, cliOutputKeys))
}
}
if artifact == nil && stream == nil {
return nil, nil, fmt.Errorf("line %d: command %q: output must declare artifact or stream (the output capabilities beyond the command-level jq key)", node.Line, cmdKey)
if artifact == nil && stream == nil && outcome == nil {
return nil, nil, nil, fmt.Errorf("line %d: command %q: output must declare artifact, stream, or outcome (the output capabilities beyond the command-level jq key)", node.Line, cmdKey)
}
return artifact, stream, nil
return artifact, stream, outcome, nil
}

// decodeOutcome decodes an output.outcome declaration. Pointer resolvability
// and enum coverage are checked during linking; this stage validates the
// local grammar: at least one state pointer, a states table whose values are
// the async classifications, and member-name error bindings.
func (d *cliManifestDecoder) decodeOutcome(owner string, node *yaml.Node) (*CLICommandOutcome, error) {
entries, err := cliMapEntries(node, owner+" output.outcome")
if err != nil {
return nil, err
}
outcome := &CLICommandOutcome{States: map[string]string{}}
for _, entry := range entries {
switch entry.Key.Value {
case "statePointer":
outcome.StateFrom, outcome.StatePointer, _, err = cliDecodeAsyncPointer(entry.Value, "output.outcome.statePointer")
case "streamStatePointer":
outcome.StreamStateFrom, outcome.StreamStatePointer, _, err = cliDecodeAsyncPointer(entry.Value, "output.outcome.streamStatePointer")
case "states":
err = d.decodeOutcomeStates(owner, entry.Value, outcome.States)
case "errorPointer":
outcome.ErrorFrom, outcome.ErrorPointer, _, err = cliDecodeAsyncPointer(entry.Value, "output.outcome.errorPointer")
case "streamErrorPointer":
outcome.StreamErrorFrom, outcome.StreamErrorPointer, _, err = cliDecodeAsyncPointer(entry.Value, "output.outcome.streamErrorPointer")
case "errorMessageField":
outcome.ErrorMessageField, err = cliDecodeArtifactFieldName(entry.Value, "output.outcome.errorMessageField")
default:
return nil, fmt.Errorf("line %d: %s output.outcome has unknown key %q%s", entry.Key.Line, owner, entry.Key.Value, cliDidYouMean(entry.Key.Value, cliOutcomeKeys))
}
if err != nil {
return nil, fmt.Errorf("line %d: %s: %w", entry.Value.Line, owner, err)
}
}
if outcome.StatePointer == "" && outcome.StreamStatePointer == "" {
return nil, fmt.Errorf("line %d: %s output.outcome requires statePointer (the state in the JSON response) or streamStatePointer (the state inside streamed events)", node.Line, owner)
}
if len(outcome.States) == 0 {
return nil, fmt.Errorf("line %d: %s output.outcome requires states (a map from every API state to pending, success, failure, or handoff)", node.Line, owner)
}
hasFailure := false
for _, class := range outcome.States {
hasFailure = hasFailure || class == "failure"
}
if !hasFailure {
return nil, fmt.Errorf("line %d: %s output.outcome.states must classify at least one state as failure; the declaration exists to fail the command on that state", node.Line, owner)
}
if outcome.ErrorPointer != "" && outcome.StatePointer == "" {
return nil, fmt.Errorf("line %d: %s output.outcome.errorPointer requires statePointer; use streamErrorPointer for streamed events", node.Line, owner)
}
if outcome.StreamErrorPointer != "" && outcome.StreamStatePointer == "" {
return nil, fmt.Errorf("line %d: %s output.outcome.streamErrorPointer requires streamStatePointer", node.Line, owner)
}
if outcome.ErrorMessageField != "" && outcome.ErrorPointer == "" && outcome.StreamErrorPointer == "" {
return nil, fmt.Errorf("line %d: %s output.outcome.errorMessageField requires errorPointer or streamErrorPointer", node.Line, owner)
}
if (outcome.ErrorPointer != "" || outcome.StreamErrorPointer != "") && outcome.ErrorMessageField == "" {
outcome.ErrorMessageField = cliAsyncDefaultErrorMessageField
}
return outcome, nil
}

func (d *cliManifestDecoder) decodeOutcomeStates(owner string, node *yaml.Node, states map[string]string) error {
entries, err := cliMapEntries(node, owner+" output.outcome.states")
if err != nil {
return err
}
for _, entry := range entries {
state, err := cliScalarString(entry.Key, "outcome state name")
if err != nil || strings.TrimSpace(state) == "" {
return fmt.Errorf("line %d: %s output.outcome.states keys must be non-empty strings", entry.Key.Line, owner)
}
class, err := cliScalarString(entry.Value, fmt.Sprintf("classification for outcome state %q", state))
if err != nil {
return err
}
if !cliContains(cliAsyncStateKind, class) {
return fmt.Errorf("line %d: %s output.outcome.states[%q] has unsupported classification %q (expected %s)", entry.Value.Line, owner, state, class, cliCandidateList(cliAsyncStateKind))
}
states[state] = class
}
return nil
}

// decodeArtifact decodes and validates an output.artifact declaration. The
Expand Down
Loading
Loading