ad150c7126
Sweep every CLI output path through the v0.7 envelope contract.
FormatOptions.Emit signature changes to (w, data, meta *output.Meta)
so list commands surface meta.count / has_more in the wire envelope.
TTY indent decision plumbed via FormatOptions.TTY (set by
ResolveDefault). --format and --jq promoted to persistent root flags
so unknown-subcommand paths still reach the typed-envelope guard
(per-command registration would have rejected --format on unknown
commands as cobra-prose exit 2). WEKNORA_FORMAT env-var fallback
honored before TTY-resolved default. AddFormatFlag retained for the
per-command --jq field-hint Long-text addendum.
~55 production Emit call sites swept across cli/cmd/**: list paths
populate meta.count; mutation paths pass nil meta; envelope.profile
threaded via globalProfile. Existing JSON-shape tests migrated to
decode the envelope wrapper.
Multi-target batch operations get a unified shape via new helpers:
- output.BatchItem + output.WriteBatchEnvelope (§4.5 per-item ok
pattern; data:[{id, ok, result?|error?}], meta:{count, successes*,
failures*}; Successes/Failures *int so explicit zero survives
omitempty in all-fail case).
- cmdutil.RunBatch + cmdutil.EmitBatch + cmdutil.DeletedAtNow +
cmdutil.ClassifyContextErr collapse the three previously-duplicated
multi-delete pipelines (doc / chunk / session) into one call site
pattern. doc upload --recursive uses the same helpers with a per-
file resultFn. Summary error sets Silent:true so the stderr
envelope path doesn't duplicate the stdout batch detail (exit 1
preserved via Code → ExitCode).
ConfirmDestructive / ConfirmDestructiveBatch extended with action +
retryCmd parameters. Every destructive command (kb delete, kb edit,
doc delete, doc delete --all, agent delete, agent edit, session
delete, chunk delete, auth logout, api -X DELETE) now attaches
error.risk.{level:"destructive", action:"<noun.verb>"} +
error.retry_command on the exit-10 envelope. kb edit / agent edit
also gain the destructive guard wiring (CHANGELOG listed them as
gated but they were silently executing without -y).
MCP tool handlers' 31 error paths return CallToolResult with
IsError + Content text fallback + StructuredContent (envelope.error
shape via cmdutil.ErrorToDetail). Handler Out type changed from
typed *sdk.X to any so the go-sdk auto-marshal doesn't overwrite
StructuredContent with a zero-struct on error returns. Success path
manually populates CallToolResult.StructuredContent via successResult
helper.
weknora api cleanup:
- -d/--data flag removed; body via --input <file> or --input -
(stdin) only. Aligns with gh / curl convention.
- HTTP method whitelist removed; any non-empty method accepted.
- HTTP Retry-After header → cmdutil.Error.RetryAfterSeconds →
envelope.error.retry_after_seconds.
- runAPIPaginated raw-passthrough fallback now wraps response in
envelope so --paginate never emits bare JSON to stdout.
Test sweep: ~60 cmd/** tests migrated to decode envelope shape.
9 wire-contract goldens updated under cli/acceptance/testdata/wire/.
Spec: docs/superpowers/specs/2026-05-20-weknora-cli-v0.7-design.md §4 / §5
157 lines
5.1 KiB
Go
157 lines
5.1 KiB
Go
package cmdutil
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/Tencent/WeKnora/cli/internal/format"
|
|
"github.com/Tencent/WeKnora/cli/internal/output"
|
|
)
|
|
|
|
// FormatMode is the resolved --format value (typed enum).
|
|
type FormatMode string
|
|
|
|
const (
|
|
FormatHuman FormatMode = "human"
|
|
FormatJSON FormatMode = "json"
|
|
FormatNDJSON FormatMode = "ndjson"
|
|
)
|
|
|
|
// FormatOptions captures the resolved --format + --jq state for a command.
|
|
// Mode is one of FormatHuman / FormatJSON / FormatNDJSON, or "" before
|
|
// ResolveDefault has been called.
|
|
type FormatOptions struct {
|
|
Mode FormatMode
|
|
JQ string
|
|
TTY bool // ResolveDefault populates; Emit reads for indent decision
|
|
}
|
|
|
|
// AddFormatFlag attaches the --jq projection field hints (in cmd.Long) for
|
|
// commands that honor --format. The flags themselves are registered as
|
|
// persistent globals at the root, so this helper no longer re-registers
|
|
// them — it only appends documentation. Callers that don't need field
|
|
// hints can skip this call entirely.
|
|
func AddFormatFlag(cmd *cobra.Command, fieldHints ...string) {
|
|
if len(fieldHints) > 0 {
|
|
sorted := append([]string(nil), fieldHints...)
|
|
sort.Strings(sorted)
|
|
hdr := "\n\nJSON fields available (for --jq projection):\n " +
|
|
strings.Join(sorted, "\n ")
|
|
if cmd.Long != "" {
|
|
cmd.Long += hdr
|
|
} else {
|
|
cmd.Long = strings.TrimSpace(cmd.Short) + hdr
|
|
}
|
|
}
|
|
}
|
|
|
|
// CheckFormatFlag resolves --format + --jq from cmd. Returns:
|
|
// - (*FormatOptions{Mode:""}, nil) flag not set; caller should call ResolveDefault
|
|
// - (*FormatOptions{Mode:v,JQ:q}, nil) valid values
|
|
// - (nil, *FlagError) invalid --format, or --jq with explicit --format human
|
|
//
|
|
// --jq with --format unset is accepted: ResolveDefault below will promote
|
|
// the mode to FormatJSON so the filter has somewhere to apply.
|
|
func CheckFormatFlag(cmd *cobra.Command) (*FormatOptions, error) {
|
|
fopts := &FormatOptions{}
|
|
if f := cmd.Flags().Lookup("format"); f != nil {
|
|
v := f.Value.String()
|
|
switch v {
|
|
case "":
|
|
// unset; caller calls ResolveDefault
|
|
case "human", "json", "ndjson":
|
|
fopts.Mode = FormatMode(v)
|
|
default:
|
|
return nil, NewFlagError(fmt.Errorf("invalid --format %q: must be human | json | ndjson", v))
|
|
}
|
|
}
|
|
if f := cmd.Flags().Lookup("jq"); f != nil {
|
|
fopts.JQ = f.Value.String()
|
|
}
|
|
// --jq only meaningful for JSON-shaped output. Reject the explicit
|
|
// `--format human --jq ...` combination; the `--jq` with --format unset
|
|
// case is handled by ResolveDefault.
|
|
if fopts.JQ != "" && fopts.Mode == FormatHuman {
|
|
return nil, NewFlagError(errors.New("--jq requires --format json|ndjson"))
|
|
}
|
|
return fopts, nil
|
|
}
|
|
|
|
// WantsJSON reports whether the resolved mode is JSON or NDJSON. Used by
|
|
// callers to choose between the JSON emit path and human text rendering.
|
|
func (o *FormatOptions) WantsJSON() bool {
|
|
return o.Mode == FormatJSON || o.Mode == FormatNDJSON
|
|
}
|
|
|
|
// Emit serializes data wrapped in the success envelope (FormatJSON
|
|
// path) or as bare NDJSON lines (FormatNDJSON path). meta is optional
|
|
// (pass nil for mutation commands without batch counts).
|
|
//
|
|
// FormatJSON path: envelope is {ok:true, data:..., meta?:..., _notice?:...}.
|
|
// Indent is determined by o.TTY (populated by ResolveDefault).
|
|
// When o.JQ is set, jq evaluates against the full envelope JSON, so users
|
|
// project with ".data[]", ".meta.count", etc.
|
|
//
|
|
// FormatNDJSON path: emits one bare JSON object per line (no envelope).
|
|
// Matches spec §5 NDJSON event-passthrough semantics.
|
|
//
|
|
// FormatHuman path returns an error so a missed dispatch surfaces loudly.
|
|
func (o *FormatOptions) Emit(w io.Writer, data any, meta *output.Meta) error {
|
|
switch o.Mode {
|
|
case FormatJSON:
|
|
if o.JQ != "" {
|
|
env := output.Envelope{
|
|
OK: true,
|
|
Data: data,
|
|
Meta: meta,
|
|
Notice: output.GetNotice(),
|
|
Profile: globalProfile,
|
|
}
|
|
return format.WriteJSONFiltered(w, env, nil, o.JQ)
|
|
}
|
|
return output.WriteEnvelope(w, data, meta, o.TTY, globalProfile)
|
|
case FormatNDJSON:
|
|
if o.JQ != "" {
|
|
return format.WriteJSONFiltered(w, data, nil, o.JQ)
|
|
}
|
|
return format.WriteNDJSON(w, data)
|
|
case FormatHuman:
|
|
return fmt.Errorf("FormatOptions.Emit: cannot emit human mode as JSON; caller must render human-readable separately")
|
|
default:
|
|
return fmt.Errorf("FormatOptions.Emit: unknown mode %q", o.Mode)
|
|
}
|
|
}
|
|
|
|
// ResolveDefault fills in Mode when the caller has not explicitly set it:
|
|
// - Mode defaults to FormatJSON
|
|
// - TTY only affects the indent decision (auto-indent in TTY; compact in pipe)
|
|
// - For human-readable rendering, pass --format human explicitly
|
|
func (o *FormatOptions) ResolveDefault(tty bool) {
|
|
o.TTY = tty
|
|
if o.Mode == "" {
|
|
o.Mode = FormatJSON
|
|
}
|
|
}
|
|
|
|
// FromEnv reads WEKNORA_FORMAT and applies it when Mode hasn't been set
|
|
// by --format. Call between CheckFormatFlag and ResolveDefault.
|
|
//
|
|
// Invalid env values are silently ignored (the user's --format on a
|
|
// later invocation will still take precedence).
|
|
func (o *FormatOptions) FromEnv() {
|
|
if o.Mode != "" {
|
|
return
|
|
}
|
|
v := os.Getenv("WEKNORA_FORMAT")
|
|
switch v {
|
|
case "human", "json", "ndjson":
|
|
o.Mode = FormatMode(v)
|
|
}
|
|
}
|