Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d6e789a
feat(cli): fail commands on a declared terminal failure state
AshGodfrey Oct 22, 2026
dadc371
fix(cli): classify output.outcome on paginated pages and link streamE…
AshGodfrey Oct 22, 2026
8ddeda4
refactor(cli): share the response-path walker and decode stream frame…
AshGodfrey Oct 22, 2026
5778e83
fix(cli): verify stream outcome states against the event enum and acc…
AshGodfrey Oct 22, 2026
7ee6314
refactor(cli): inherit operation outcome tables into intents, decode …
AshGodfrey Oct 22, 2026
f2a3e94
fix(cli): accept string failure detail, index segments in the artifac…
AshGodfrey Oct 22, 2026
7184697
Merge remote-tracking branch 'origin/main' into ash/cli-terminal-states
AshGodfrey Oct 22, 2026
7a7fa85
test(cli): refresh release snapshots for the outcome-failure README p…
AshGodfrey Oct 22, 2026
acbb4b5
fix(cli): intersect allOf and ref-sibling enums when verifying outcom…
AshGodfrey Oct 22, 2026
a8092ec
fix(cli): skip null enum members in async state pointers
TristanSpeakEasy Oct 26, 2026
9fa7b4e
fix(cli): classify output.outcome for --output-file and --output-b64
TristanSpeakEasy Oct 26, 2026
fd88b45
fix(cli): keep $ref sibling constraints when walking stream paths
TristanSpeakEasy Oct 26, 2026
5209510
Merge remote-tracking branch 'origin/main' into ash/cli-terminal-states
TristanSpeakEasy Oct 27, 2026
3b301c7
fix(cli): apply oneOf/anyOf semantics when verifying stream outcome s…
TristanSpeakEasy Oct 27, 2026
2d84963
Merge remote-tracking branch 'origin/main' into ash/cli-terminal-states
AshGodfrey Oct 29, 2026
17ff538
Merge remote-tracking branch 'origin/main' into ash/cli-terminal-states
AshGodfrey Oct 29, 2026
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 @@ -243,6 +243,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 @@ -509,6 +533,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 @@ -527,7 +552,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 @@ -782,17 +809,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 @@ -953,7 +994,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 @@ -962,6 +1003,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 @@ -976,6 +1018,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 @@ -1004,7 +1049,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 @@ -1336,38 +1381,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