733bb3aaa1
Re-introduce the agent-first symmetric envelope deleted in commite623e820(2026-05-15). Under the v0.7 constraint that AI agents are primary consumers, the bare-JSON shape can't carry protocol channels (_notice / risk / meta / request_id / profile) that agents need. Errors-on-stderr and typed exit codes frome623e820are preserved; the envelope wraps the success/error payloads on top. - cli/internal/output/envelope.go (new): Envelope / ErrorEnvelope / Meta / ErrDetail / RiskDetail structs; WriteEnvelope + WriteErrorEnvelope writers; PendingNotice plumbing for the open-map _notice channel (reserved infra; producer wiring planned for v0.8). - cli/internal/output/envelope_test.go (new): 7 tests covering success / error / TTY indent / Notice / Risk shapes. - cmdutil.Error extended with RetryCommand (directly-executable argv distinct from prose Hint), RetryAfterSeconds (HTTP Retry-After), *RiskInfo (nested level+action; ErrInternalServer- vs-NotFound distinction), Detail (open structured payload), Silent (suppress stderr emit while preserving Code for ExitCode). - ErrorToDetail single source of envelope.error construction; reused by stderr PrintError, MCP StructuredContent, and batch per-item error path. - WithHint / WithRetryCommand / WithRetryAfter / WithDetail / WithRisk builders. IsCancelled helper. AsError unwrap helper. - defaultHint covers ~21 codes; defaultRetryCommand symmetric counterpart for 6 keyway codes. DefaultHint / DefaultRetryCommand exported wrappers for cross-package callers. - ClassifyHTTPError tightened: rescues HTTP 500 with structured server-side code 1003 (ErrNotFound) into resource.not_found. Server's generic 1007 (ErrInternalServer) bucket stays as server.error — including it would mis-route validation failures (e.g. SQLSTATE 22001) as not-found. - PrintError dual-mode: text/human → prose with code:msg / hint / retry lines; json/ndjson → envelope on stderr. Mode pinned by root PersistentPreRunE via SetFormatMode. resolveFormatEarly() scans argv before cobra dispatch so cobra-side validators (unknown flag, arg-count) still surface as envelope when --format json is in effect. Silent typed errors short-circuit the stderr emit. FlagError mapped to input.invalid_argument for the envelope (exit code stays 2 via FlagError class). - Unknown-subcommand guard installed recursively at the root: parents with subcommands but no Run/RunE get a typed RunE that emits input.unknown_subcommand envelope with detail.{unknown, command_path, available[]} and retry_command "<parent> --help". cobra.ArbitraryArgs bypasses legacyArgs validation so the guard receives unmatched argv. - cmdutil.Error.Error() returns "<code>: <message>[: <cause>]" for chain debugging; ErrorToDetail strips the code prefix from envelope.message since the separate type field carries it (prevents the doubled-prefix "code: code: ..." that agents would see). Spec: docs/superpowers/specs/2026-05-20-weknora-cli-v0.7-design.md §0 / §4
106 lines
3.5 KiB
Go
106 lines
3.5 KiB
Go
// Package output defines the symmetric envelope wire contract:
|
|
// success envelopes on stdout (Envelope) and error envelopes on
|
|
// stderr (ErrorEnvelope), plus NDJSON stream helpers.
|
|
package output
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
)
|
|
|
|
// Envelope is the success-path stdout envelope (§4.1).
|
|
type Envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data any `json:"data,omitempty"`
|
|
Meta *Meta `json:"meta,omitempty"`
|
|
Notice map[string]any `json:"_notice,omitempty"`
|
|
Profile string `json:"profile,omitempty"`
|
|
}
|
|
|
|
// ErrorEnvelope is the error-path stderr envelope (§4.2).
|
|
type ErrorEnvelope struct {
|
|
OK bool `json:"ok"`
|
|
Error *ErrDetail `json:"error"`
|
|
Notice map[string]any `json:"_notice,omitempty"`
|
|
}
|
|
|
|
// Meta carries optional metadata in success envelopes (§4.3).
|
|
type Meta struct {
|
|
Count int `json:"count,omitempty"`
|
|
HasMore bool `json:"has_more,omitempty"`
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
TotalCount int `json:"total_count,omitempty"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
// Successes and Failures are *int so zero is serialized when explicitly set
|
|
// by the batch path (omitempty on *int omits only nil, not zero).
|
|
// Non-batch commands leave these nil so they are omitted from the envelope.
|
|
Successes *int `json:"successes,omitempty"` // batch ops
|
|
Failures *int `json:"failures,omitempty"` // batch ops
|
|
}
|
|
|
|
// ErrDetail describes a structured error (§4.2).
|
|
type ErrDetail struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
Hint string `json:"hint,omitempty"`
|
|
RetryCommand string `json:"retry_command,omitempty"`
|
|
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
|
Risk *RiskDetail `json:"risk,omitempty"`
|
|
Detail any `json:"detail,omitempty"`
|
|
}
|
|
|
|
// RiskDetail tags high-risk writes for agent protocol (§4.2 error.risk).
|
|
// Level: only "destructive" is emitted; "read" / "write" slots reserved.
|
|
type RiskDetail struct {
|
|
Level string `json:"level"`
|
|
Action string `json:"action"`
|
|
}
|
|
|
|
// PendingNotice, if set, returns system-level notices to inject as the
|
|
// "_notice" field on every envelope. Currently nil — Task 4.x deferred
|
|
// the registration. Tests may set this directly.
|
|
var PendingNotice func() map[string]any
|
|
|
|
// GetNotice returns the current pending notice. Nil when nothing to report.
|
|
func GetNotice() map[string]any {
|
|
if PendingNotice == nil {
|
|
return nil
|
|
}
|
|
return PendingNotice()
|
|
}
|
|
|
|
// WriteEnvelope writes a success envelope to w. Caller sets data + optional meta;
|
|
// notice is injected from GetNotice() automatically.
|
|
//
|
|
// When profile is non-empty, the envelope includes a "profile" field.
|
|
// indent: if true, output is multi-line (TTY mode); else compact (pipe mode).
|
|
func WriteEnvelope(w io.Writer, data any, meta *Meta, indent bool, profile string) error {
|
|
env := Envelope{
|
|
OK: true,
|
|
Data: data,
|
|
Meta: meta,
|
|
Notice: GetNotice(),
|
|
Profile: profile,
|
|
}
|
|
return writeJSON(w, env, indent)
|
|
}
|
|
|
|
// WriteErrorEnvelope writes an error envelope to w (typically stderr).
|
|
func WriteErrorEnvelope(w io.Writer, err *ErrDetail, indent bool) error {
|
|
env := ErrorEnvelope{
|
|
OK: false,
|
|
Error: err,
|
|
Notice: GetNotice(),
|
|
}
|
|
return writeJSON(w, env, indent)
|
|
}
|
|
|
|
func writeJSON(w io.Writer, v any, indent bool) error {
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
if indent {
|
|
enc.SetIndent("", " ")
|
|
}
|
|
return enc.Encode(v)
|
|
}
|