Files
Enterprise-Knowledge-Base/cli/internal/output/envelope.go
T
nullkey 733bb3aaa1 refactor(cli): symmetric envelope infrastructure (supersedes e623e820)
Re-introduce the agent-first symmetric envelope deleted in commit
e623e820 (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 from e623e820 are 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
2026-05-27 10:56:34 +08:00

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)
}