Files
WeKnora/cli/internal/cmdutil/format.go
T
nullkey 567d7ac74e feat(cli): --format / NDJSON / chat & agent invoke / MCP / SetAgentHelp / signal-aware ctx / kb create --storage-provider
Adds the structured-output and agent-help surface plus root-level signal
handling so AI agents (and humans working through pipes) get a stable
wire contract.

* --format text|json|ndjson flag, registered per-command on outputs that
  need it; default text on TTY, json on pipe. --jq <expr> pairs with json
  / ndjson to filter or project. FormatOptions absorbs JQ; WantsJSON()
  helper for the JSON dispatch.
* WriteNDJSON helper in internal/format/ (per ndjson.org: one JSON value
  per line, arrays split element-per-line, empty slice → zero bytes).
* chat / agent invoke wire --format ndjson via SDK StreamResponse /
  AgentStreamResponse 1:1 passthrough. Both commands detect ctx.Cancelled
  in every stream + session-create path and emit a stable
  "operation.cancelled" code on Ctrl-C / SIGTERM.
* main.go wires signal.NotifyContext(SIGINT, SIGTERM) into the root
  context so long-running commands run their cancellation cleanup
  (re-emit auto-created session id, etc); the process exits 130 when
  the context was signal-cancelled, matching Unix convention.
* MCP chat / agent_invoke output schemas extended with thinking /
  tool_calls / assistant_message_id (server-side accumulated; MCP
  tools/call has no standard partial-response). doc_view and doc_download
  now use doc_id (not knowledge_id) so agents see a single id naming
  convention across all tools — matches the chunk_list / search_chunks
  schemas and the CLI's <doc-id> positional.
* SetAgentHelp(cmd, AgentHelp{...}) — opt-in machine-friendly --help
  payload activated by WEKNORA_AGENT_HELP=1. Applied to chat / kb list.
* kb create --storage-provider <local|minio|cos|tos|s3|oss|ks3> — sets
  the new KB's storage_provider_config.provider at creation time (server
  does not expose it on update). Required on self-hosted deployments
  where the server-side default doesn't pre-populate a provider —
  without it, subsequent doc upload returns a misleading "kb not found".
2026-05-18 11:10:19 +08:00

136 lines
4.6 KiB
Go

package cmdutil
import (
"errors"
"fmt"
"io"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/format"
)
// Typed format modes. Defined as constants to keep mode comparisons
// type-safe and prevent string drift across call sites.
const (
FormatText = "text"
FormatJSON = "json"
FormatNDJSON = "ndjson"
)
// FormatOptions captures the resolved --format + --jq state for a command.
// Mode is one of FormatText / FormatJSON / FormatNDJSON, or "" before
// ResolveDefault has been called.
type FormatOptions struct {
Mode string
JQ string
}
// AddFormatFlag registers --format and --jq on cmd. Optional fieldHints are
// appended to cmd.Long as a documentation aid for --jq projection
// (e.g., `weknora kb list --format json --jq '.[] | {id, name}'`).
//
// Uses cmd.Flags() (local) rather than a root persistent flag — only
// commands that actually honor --format register it, so cobra rejects
// --format on others with "unknown flag" rather than silently ignoring it.
func AddFormatFlag(cmd *cobra.Command, fieldHints ...string) {
cmd.Flags().String("format", "", "Output format: text | json | ndjson (default: text in TTY, json in pipe)")
cmd.Flags().StringP("jq", "q", "", "Filter JSON output using a jq `expression` (requires --format json|ndjson)")
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 text
//
// --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 "", FormatText, FormatJSON, FormatNDJSON:
fopts.Mode = v
default:
return nil, NewFlagError(fmt.Errorf("invalid --format %q: must be text | 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 text --jq ...` combination; the `--jq` with --format unset
// case is handled by ResolveDefault.
if fopts.JQ != "" && fopts.Mode == FormatText {
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 v according to the resolved Mode + JQ. For ndjson, slice
// values are split element-per-line (per ndjson.org); for json, v is
// emitted as-is. When JQ is set, the jq expression runs against the
// marshaled JSON and each result becomes a line. Text mode is the caller's
// job — Emit returns an error so a missed dispatch surfaces loudly.
func (o *FormatOptions) Emit(w io.Writer, v any) error {
switch o.Mode {
case FormatJSON:
return format.WriteJSONFiltered(w, v, nil, o.JQ)
case FormatNDJSON:
// jq output is naturally line-per-result → already valid NDJSON.
// Without jq, split arrays per ndjson.org (one object per line).
if o.JQ != "" {
return format.WriteJSONFiltered(w, v, nil, o.JQ)
}
return format.WriteNDJSON(w, v)
case FormatText:
return fmt.Errorf("FormatOptions.Emit: cannot emit text mode as JSON; caller must render text separately")
default:
return fmt.Errorf("FormatOptions.Emit: unknown mode %q", o.Mode)
}
}
// ResolveDefault fills in Mode using TTY detection when the user didn't pass
// --format explicitly. Pass iostreams.IO.IsStdoutTTY() as isTTY.
// No-op if Mode is already set.
//
// `--jq` without an explicit `--format` forces JSON regardless of TTY:
// the filter has no meaning in text mode, so silently dropping it would
// surprise the user. The `--jq` + `--format text` combination is caught
// up-front by CheckFormatFlag.
func (o *FormatOptions) ResolveDefault(isTTY bool) {
if o.Mode != "" {
return
}
if o.JQ != "" {
o.Mode = FormatJSON
return
}
if isTTY {
o.Mode = FormatText
} else {
o.Mode = FormatJSON
}
}