mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-29 02:04:30 +08:00
refactor(cli): symmetric envelope infrastructure (supersedes e623e820)
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
This commit is contained in:
@@ -165,6 +165,10 @@ func identToErrorCode(name string) (cmdutil.ErrorCode, bool) {
|
||||
return cmdutil.CodeInputInvalidArgument, true
|
||||
case "CodeInputMissingFlag":
|
||||
return cmdutil.CodeInputMissingFlag, true
|
||||
case "CodeInputConfirmationRequired":
|
||||
return cmdutil.CodeInputConfirmationRequired, true
|
||||
case "CodeInputUnknownSubcommand":
|
||||
return cmdutil.CodeInputUnknownSubcommand, true
|
||||
case "CodeServerError":
|
||||
return cmdutil.CodeServerError, true
|
||||
case "CodeServerTimeout":
|
||||
@@ -183,8 +187,8 @@ func identToErrorCode(name string) (cmdutil.ErrorCode, bool) {
|
||||
return cmdutil.CodeLocalFileIO, true
|
||||
case "CodeLocalUnimplemented":
|
||||
return cmdutil.CodeLocalUnimplemented, true
|
||||
case "CodeLocalContextNotFound":
|
||||
return cmdutil.CodeLocalContextNotFound, true
|
||||
case "CodeLocalProfileNotFound":
|
||||
return cmdutil.CodeLocalProfileNotFound, true
|
||||
case "CodeKBIDRequired":
|
||||
return cmdutil.CodeKBIDRequired, true
|
||||
case "CodeKBNotFound":
|
||||
|
||||
+130
-22
@@ -4,6 +4,8 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -13,24 +15,65 @@ import (
|
||||
"github.com/Tencent/WeKnora/cli/cmd/auth"
|
||||
chatcmd "github.com/Tencent/WeKnora/cli/cmd/chat"
|
||||
chunkcmd "github.com/Tencent/WeKnora/cli/cmd/chunk"
|
||||
contextcmd "github.com/Tencent/WeKnora/cli/cmd/context"
|
||||
"github.com/Tencent/WeKnora/cli/cmd/doc"
|
||||
"github.com/Tencent/WeKnora/cli/cmd/doctor"
|
||||
"github.com/Tencent/WeKnora/cli/cmd/kb"
|
||||
linkcmd "github.com/Tencent/WeKnora/cli/cmd/link"
|
||||
mcpcmd "github.com/Tencent/WeKnora/cli/cmd/mcp"
|
||||
profilecmd "github.com/Tencent/WeKnora/cli/cmd/profile"
|
||||
"github.com/Tencent/WeKnora/cli/cmd/search"
|
||||
sessioncmd "github.com/Tencent/WeKnora/cli/cmd/session"
|
||||
"github.com/Tencent/WeKnora/cli/internal/build"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
// resolveFormatEarly scans raw argv for --format before cobra's command
|
||||
// dispatch. This ensures globalFormatMode is set before any cobra-side
|
||||
// validator fires (unknown flag, arg count, etc.), so PrintError routes
|
||||
// those errors through the JSON envelope when --format json is in effect.
|
||||
//
|
||||
// Call order: resolveFormatEarly → cobra Execute → PersistentPreRunE (which
|
||||
// re-runs CheckFormatFlag and calls SetFormatMode again with the same value).
|
||||
func resolveFormatEarly(args []string) {
|
||||
var mode string
|
||||
for i, a := range args {
|
||||
if a == "--format" && i+1 < len(args) {
|
||||
mode = strings.ToLower(args[i+1])
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(a, "--format=") {
|
||||
mode = strings.ToLower(strings.TrimPrefix(a, "--format="))
|
||||
break
|
||||
}
|
||||
}
|
||||
if mode == "" {
|
||||
if v := os.Getenv("WEKNORA_FORMAT"); v != "" {
|
||||
mode = strings.ToLower(v)
|
||||
}
|
||||
}
|
||||
switch mode {
|
||||
case "json", "ndjson", "human":
|
||||
cmdutil.SetFormatMode(mode)
|
||||
case "":
|
||||
// nothing to set; leave globalFormatMode at its zero value
|
||||
default:
|
||||
// Invalid format value: promote to json so the subsequent
|
||||
// rejection error (from CheckFormatFlag) still emits as an envelope
|
||||
// rather than prose. The real validation error will still fire.
|
||||
cmdutil.SetFormatMode("json")
|
||||
}
|
||||
}
|
||||
|
||||
// Execute is the entry point invoked by main(). Returns the process exit code.
|
||||
// The passed context is wired to OS signals (SIGINT / SIGTERM) by main so
|
||||
// commands that respect cmd.Context() can run their cancellation cleanup.
|
||||
func Execute(ctx context.Context) int {
|
||||
// Resolve --format early so cobra-side errors (unknown flag, arg-count
|
||||
// violations) still route through PrintError's JSON envelope path when
|
||||
// --format json is in effect. PersistentPreRunE will call SetFormatMode
|
||||
// again after full flag parse - idempotent when the value matches.
|
||||
resolveFormatEarly(os.Args[1:])
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
if err := root.ExecuteContext(ctx); err != nil {
|
||||
// Errors go to stderr. Stdout stays
|
||||
@@ -78,7 +121,7 @@ var cobraFlagErrorPrefixes = []string{
|
||||
"requires at least", // MinimumNArgs
|
||||
"requires at most", // MaximumNArgs
|
||||
"unknown flag",
|
||||
"invalid argument", // pflag type-coercion failure (e.g. --limit=foo)
|
||||
"invalid argument \"", // pflag type-coercion: `invalid argument "foo" for "--flag" flag`
|
||||
}
|
||||
|
||||
// NewRootCmd builds the cobra tree. Splitting it from Execute() lets tests
|
||||
@@ -104,11 +147,24 @@ a curated read-only MCP tool surface for AI agents.`,
|
||||
// (build commit + date).
|
||||
Version: fmt.Sprintf("%s (commit %s, built %s)", v, commit, date),
|
||||
PersistentPreRunE: func(c *cobra.Command, args []string) error {
|
||||
// Propagate the global --context flag into the Factory for this
|
||||
// invocation only - single-shot override, no disk write.
|
||||
if v, _ := c.Flags().GetString("context"); v != "" {
|
||||
f.ContextOverride = v
|
||||
// Propagate the global --profile flag (or WEKNORA_PROFILE env) into
|
||||
// the Factory for this invocation only - single-shot override, no disk write.
|
||||
// Flag takes precedence over env; env takes precedence over config file.
|
||||
if v, _ := c.Flags().GetString("profile"); v != "" {
|
||||
f.ProfileOverride = v
|
||||
} else if v := os.Getenv("WEKNORA_PROFILE"); v != "" {
|
||||
f.ProfileOverride = v
|
||||
}
|
||||
// Pin --format mode for cmdutil.PrintError envelope vs prose decision.
|
||||
// Safe on commands that don't register --format: CheckFormatFlag returns
|
||||
// {Mode:""}, ResolveDefault falls back to TTY detection.
|
||||
if fopts, err := cmdutil.CheckFormatFlag(c); err == nil && fopts != nil {
|
||||
fopts.FromEnv()
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cmdutil.SetFormatMode(string(fopts.Mode))
|
||||
}
|
||||
// Record the resolved profile for envelope.profile and NDJSON init.profile.
|
||||
cmdutil.SetProfile(f.ActiveProfile())
|
||||
// Resolve --log-level / WEKNORA_LOG_LEVEL and apply to the SDK
|
||||
// debug logger before any SDK call is made. Returns a typed error
|
||||
// when --log-level was passed explicitly with an invalid value
|
||||
@@ -131,7 +187,7 @@ a curated read-only MCP tool surface for AI agents.`,
|
||||
cmd.AddCommand(search.NewCmdSearch(f))
|
||||
cmd.AddCommand(doctor.NewCmd(f))
|
||||
cmd.AddCommand(kb.NewCmd(f))
|
||||
cmd.AddCommand(contextcmd.NewCmd(f))
|
||||
cmd.AddCommand(profilecmd.NewCmd(f))
|
||||
cmd.AddCommand(linkcmd.NewCmd(f))
|
||||
cmd.AddCommand(linkcmd.NewCmdUnlink())
|
||||
cmd.AddCommand(doc.NewCmd(f))
|
||||
@@ -141,6 +197,7 @@ a curated read-only MCP tool surface for AI agents.`,
|
||||
cmd.AddCommand(agentcmd.NewCmd(f))
|
||||
cmd.AddCommand(chunkcmd.NewCmdChunk(f))
|
||||
cmd.AddCommand(mcpcmd.NewCmd(f))
|
||||
installUnknownSubcommandGuard(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -150,17 +207,20 @@ a curated read-only MCP tool surface for AI agents.`,
|
||||
func addGlobalFlags(cmd *cobra.Command) {
|
||||
pf := cmd.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "Skip confirmation prompts on destructive operations")
|
||||
pf.String("context", "", "Override the active context for this invocation (no disk write)")
|
||||
pf.String("profile", "", "Override the active profile for this invocation (no disk write)")
|
||||
// --log-level is registered as a persistent (global) flag because the SDK
|
||||
// debug logger is initialised once at factory time before any command runs,
|
||||
// so the flag must be visible on all subcommands. Unlike --format (which
|
||||
// only some commands honour and is registered per-command, Method D),
|
||||
// --log-level applies uniformly to all SDK calls.
|
||||
cmdutil.AddLogLevelFlag(cmd)
|
||||
// NOTE: --format is registered per-command (cmdutil.AddFormatFlag in each
|
||||
// command's NewCmd). Only commands that actually honor --format register
|
||||
// it; cobra rejects --format on others with "unknown flag" rather than
|
||||
// silently ignoring it.
|
||||
// --format and --jq are persistent globals so unknown-subcommand paths
|
||||
// (e.g. `weknora fooo --format json`) reach the typed-envelope guard
|
||||
// instead of being rejected as "unknown flag" exit 2 by cobra. Commands
|
||||
// that don't produce JSON output (e.g. `completion bash`) ignore the flag
|
||||
// rather than error — the unified agent contract is worth the trade.
|
||||
pf.String("format", "", "Output format: human | json | ndjson (default: json)")
|
||||
pf.StringP("jq", "q", "", "Filter JSON output using a jq `expression` (requires --format json|ndjson)")
|
||||
}
|
||||
|
||||
// versionFields enumerates the fields surfaced for `--format json` discovery on
|
||||
@@ -182,15 +242,11 @@ func newVersionCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
v, commit, date := build.Info()
|
||||
if fopts.WantsJSON() {
|
||||
return format.WriteJSONFiltered(
|
||||
c.OutOrStdout(),
|
||||
map[string]string{
|
||||
"version": v,
|
||||
"commit": commit,
|
||||
"date": date,
|
||||
},
|
||||
nil, fopts.JQ,
|
||||
)
|
||||
return fopts.Emit(c.OutOrStdout(), map[string]string{
|
||||
"version": v,
|
||||
"commit": commit,
|
||||
"date": date,
|
||||
}, nil)
|
||||
}
|
||||
fmt.Fprintf(c.OutOrStdout(), "weknora %s (commit %s, built %s)\n", v, commit, date)
|
||||
return nil
|
||||
@@ -199,3 +255,55 @@ func newVersionCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.AddFormatFlag(cmd, versionFields...)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard recursively attaches a RunE that emits a typed
|
||||
// envelope error when a parent command is invoked with no matching subcommand
|
||||
// (e.g. `weknora kb bogus`). Without this, cobra falls back to a free-form
|
||||
// "unknown command" string error via legacyArgs validation.
|
||||
//
|
||||
// cobra's legacyArgs (args.go) fires at Find() time when Args == nil:
|
||||
// for root commands it rejects any unrecognised positional before RunE runs.
|
||||
// Setting cobra.ArbitraryArgs bypasses that check so our RunE receives the
|
||||
// unknown arg and can emit the typed envelope instead.
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
installUnknownSubcommandGuard(c)
|
||||
}
|
||||
}
|
||||
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
// Group command invoked with no subcommand (e.g. `weknora kb`):
|
||||
// show help rather than emit a confusing `unknown ""` error.
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
return cmdutil.NewError(
|
||||
cmdutil.CodeInputUnknownSubcommand,
|
||||
fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()),
|
||||
).
|
||||
WithHint(fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))).
|
||||
WithRetryCommand(cmd.CommandPath() + " --help").
|
||||
WithDetail(map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"available": available,
|
||||
})
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
var names []string
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
|
||||
continue
|
||||
}
|
||||
names = append(names, c.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
+36
-14
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -31,10 +32,14 @@ func TestVersion_JSON(t *testing.T) {
|
||||
root.SetOut(&out)
|
||||
require.NoError(t, root.Execute())
|
||||
got := out.String()
|
||||
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{`), "expected bare JSON object, got: %q", got)
|
||||
assert.Contains(t, got, `"version":"`)
|
||||
assert.NotContains(t, got, `"ok":`)
|
||||
assert.NotContains(t, got, `"data":`)
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got: %q", got)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.NotNil(t, env.Data, "envelope.data must be present")
|
||||
assert.Contains(t, got, `"version":`)
|
||||
}
|
||||
|
||||
// Smoke test for cmdutil.ExitCode wiring; full coverage lives in
|
||||
@@ -52,14 +57,19 @@ func TestExecute_ExitCodeSurface(t *testing.T) {
|
||||
// provides them).
|
||||
func TestMapCobraError_PinnedPrefixes(t *testing.T) {
|
||||
t.Run("unknown command", func(t *testing.T) {
|
||||
// With installUnknownSubcommandGuard in place, unknown root-level
|
||||
// subcommands now return a typed *cmdutil.Error (CodeInputUnknownSubcommand)
|
||||
// rather than cobra's legacy "unknown command" text. The cobraFlagErrorPrefixes
|
||||
// fallback remains for any path that bypasses the guard.
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
root.SetArgs([]string{"bogus"})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.HasPrefix(err.Error(), "unknown command "),
|
||||
"cobra unknown-command prefix changed; update cobraFlagErrorPrefixes. got: %q", err.Error())
|
||||
typed := cmdutil.AsError(err)
|
||||
require.NotNil(t, typed, "expected typed *cmdutil.Error; got %T: %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeInputUnknownSubcommand, typed.Code)
|
||||
})
|
||||
|
||||
t.Run("required flag(s)", func(t *testing.T) {
|
||||
@@ -111,21 +121,33 @@ func TestMapCobraError(t *testing.T) {
|
||||
var fe *cmdutil.FlagError
|
||||
assert.True(t, errors.As(err, &fe))
|
||||
})
|
||||
t.Run("pflag invalid argument wraps as FlagError", func(t *testing.T) {
|
||||
// pflag emits: `invalid argument "foo" for "--limit" flag`
|
||||
err := MapCobraError(errors.New(`invalid argument "foo" for "--limit" flag: strconv.ParseInt: parsing "foo": invalid syntax`))
|
||||
var fe *cmdutil.FlagError
|
||||
assert.True(t, errors.As(err, &fe), "pflag-shaped invalid argument should become FlagError")
|
||||
})
|
||||
t.Run("domain invalid argument does not wrap", func(t *testing.T) {
|
||||
// Domain code writing fmt.Errorf("invalid argument: ...") must NOT become FlagError.
|
||||
err := MapCobraError(errors.New("invalid argument: kb id cannot be empty"))
|
||||
var fe *cmdutil.FlagError
|
||||
assert.False(t, errors.As(err, &fe), "domain-shaped invalid argument must not become FlagError")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRoot_ContextFlagPropagation guards the cobra → Factory wiring of the
|
||||
// global --context flag. Without this, a future refactor that disconnects
|
||||
// PersistentPreRun from f.ContextOverride would only fail e2e - the
|
||||
// per-package TestFactory_ContextOverride only proves the Factory side.
|
||||
func TestRoot_ContextFlagPropagation(t *testing.T) {
|
||||
// TestRoot_ProfileFlagPropagation guards the cobra → Factory wiring of the
|
||||
// global --profile flag. Without this, a future refactor that disconnects
|
||||
// PersistentPreRun from f.ProfileOverride would only fail e2e - the
|
||||
// per-package TestFactory_ProfileOverride only proves the Factory side.
|
||||
func TestRoot_ProfileFlagPropagation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"no flag", []string{"version"}, ""},
|
||||
{"global before subcmd", []string{"--context", "staging", "version"}, "staging"},
|
||||
{"--context=value form", []string{"--context=prod", "version"}, "prod"},
|
||||
{"global before subcmd", []string{"--profile", "staging", "version"}, "staging"},
|
||||
{"--profile=value form", []string{"--profile=prod", "version"}, "prod"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -135,7 +157,7 @@ func TestRoot_ContextFlagPropagation(t *testing.T) {
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
require.NoError(t, root.Execute())
|
||||
assert.Equal(t, tc.want, f.ContextOverride)
|
||||
assert.Equal(t, tc.want, f.ProfileOverride)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// TestAgentInvoke_NowReturnsUnknownSubcommand verifies the deleted v0.6
|
||||
// command emits a typed envelope rather than cobra's free-form exit-2 prose.
|
||||
func TestAgentInvoke_NowReturnsUnknownSubcommand(t *testing.T) {
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
root.SetArgs([]string{"agent", "invoke", "ag_x", "q"})
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
err := root.Execute()
|
||||
ce := cmdutil.AsError(err)
|
||||
if ce == nil || ce.Code != cmdutil.CodeInputUnknownSubcommand {
|
||||
t.Errorf("expected CodeInputUnknownSubcommand, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommand_EmitsTypedEnvelope(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFormatMode("") })
|
||||
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
var stderr bytes.Buffer
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"fooo"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown-subcommand error, got nil")
|
||||
}
|
||||
|
||||
// Force JSON mode for PrintError regardless of how PersistentPreRunE
|
||||
// resolved it during the test invocation (no TTY in test buffer).
|
||||
cmdutil.SetFormatMode("json")
|
||||
mapped := MapCobraError(err)
|
||||
cmdutil.PrintError(&stderr, mapped)
|
||||
|
||||
got := stderr.String()
|
||||
if !strings.Contains(got, `"type":"input.unknown_subcommand"`) {
|
||||
t.Errorf("expected typed code; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"available":[`) {
|
||||
t.Errorf("expected detail.available[]; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"retry_command":"weknora --help"`) {
|
||||
t.Errorf("expected retry_command; got %q", got)
|
||||
}
|
||||
}
|
||||
+158
-10
@@ -3,10 +3,14 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
)
|
||||
|
||||
// ErrorCode is a namespaced stable identifier emitted on stderr in the
|
||||
@@ -37,6 +41,11 @@ const (
|
||||
// surface the error to the user and only retry with -y after explicit
|
||||
// human approval; never auto-retry.
|
||||
CodeInputConfirmationRequired ErrorCode = "input.confirmation_required"
|
||||
// CodeInputUnknownSubcommand marks an invocation that reached a parent
|
||||
// command path but the first positional argument did not match any
|
||||
// registered subcommand. Detail includes an `available` list so agents
|
||||
// can surface valid choices without re-invoking; retry with `<path> --help`.
|
||||
CodeInputUnknownSubcommand ErrorCode = "input.unknown_subcommand"
|
||||
|
||||
// server.* / network.*
|
||||
CodeServerError ErrorCode = "server.error"
|
||||
@@ -72,7 +81,7 @@ const (
|
||||
CodeLocalKeychainDenied ErrorCode = "local.keychain_denied"
|
||||
CodeLocalFileIO ErrorCode = "local.file_io"
|
||||
CodeLocalUnimplemented ErrorCode = "local.unimplemented"
|
||||
CodeLocalContextNotFound ErrorCode = "local.context_not_found"
|
||||
CodeLocalProfileNotFound ErrorCode = "local.profile_not_found"
|
||||
// KB-resolution chain and project-link codes.
|
||||
CodeKBIDRequired ErrorCode = "local.kb_id_required"
|
||||
CodeKBNotFound ErrorCode = "local.kb_not_found"
|
||||
@@ -103,17 +112,26 @@ const (
|
||||
// `code: message[: cause]\nhint: ...` form. Exit code is derived by
|
||||
// ExitCode().
|
||||
type Error struct {
|
||||
Code ErrorCode
|
||||
Message string
|
||||
Hint string
|
||||
Cause error
|
||||
Retryable bool
|
||||
HTTPStatus int
|
||||
Code ErrorCode
|
||||
Message string
|
||||
Hint string
|
||||
Cause error
|
||||
// Silent suppresses PrintError's stderr output while preserving the
|
||||
// typed Code for ExitCode. Set by commands that already wrote their
|
||||
// own output (e.g. bulk operations reporting partial-success data on
|
||||
// stdout) but still need to surface a non-zero exit code.
|
||||
Silent bool
|
||||
Silent bool
|
||||
RetryCommand string // Directly-executable argv, distinct from prose Hint
|
||||
RetryAfterSeconds int // HTTP Retry-After header semantics (transport-level retry hint)
|
||||
Detail any // Structured detail for envelope.error.detail (e.g. unknown-subcommand available[])
|
||||
Risk *RiskInfo
|
||||
}
|
||||
|
||||
// RiskInfo tags an error with destructive-write metadata that surfaces
|
||||
// in the wire envelope's error.risk field.
|
||||
type RiskInfo struct {
|
||||
Level string
|
||||
Action string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
@@ -128,6 +146,101 @@ func (e *Error) Error() string {
|
||||
|
||||
func (e *Error) Unwrap() error { return e.Cause }
|
||||
|
||||
// WithHint sets a prose-style actionable hint.
|
||||
func (e *Error) WithHint(hint string) *Error {
|
||||
e.Hint = hint
|
||||
return e
|
||||
}
|
||||
|
||||
// WithRetryCommand sets the directly-executable retry argv string.
|
||||
// Agent 端不用 regex 从 prose hint 提 argv。
|
||||
// Empty string for codes without a canonical retry command.
|
||||
func (e *Error) WithRetryCommand(cmd string) *Error {
|
||||
e.RetryCommand = cmd
|
||||
return e
|
||||
}
|
||||
|
||||
// WithRetryAfter sets the retry_after_seconds hint (from HTTP Retry-After header).
|
||||
func (e *Error) WithRetryAfter(s int) *Error {
|
||||
e.RetryAfterSeconds = s
|
||||
return e
|
||||
}
|
||||
|
||||
// WithDetail attaches structured error.detail (e.g. unknown-subcommand available[]).
|
||||
func (e *Error) WithDetail(d any) *Error {
|
||||
e.Detail = d
|
||||
return e
|
||||
}
|
||||
|
||||
// WithRisk tags a high-risk write (destructive deletes etc.) for the agent protocol.
|
||||
func (e *Error) WithRisk(level, action string) *Error {
|
||||
e.Risk = &RiskInfo{Level: level, Action: action}
|
||||
return e
|
||||
}
|
||||
|
||||
// AsError unwraps to *Error if the chain contains one. Returns nil if not found.
|
||||
func AsError(err error) *Error {
|
||||
var typed *Error
|
||||
if errors.As(err, &typed) {
|
||||
return typed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorToDetail converts a typed cmdutil.Error (or fallback) into
|
||||
// output.ErrDetail for embedding in success-envelope batch items or
|
||||
// MCP CallToolResult StructuredContent. Hint / RetryCommand fall back
|
||||
// to defaultHint / defaultRetryCommand when typed value is empty.
|
||||
// Returns nil when err is nil.
|
||||
func ErrorToDetail(err error) *output.ErrDetail {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if typed := AsError(err); typed != nil {
|
||||
hint := typed.Hint
|
||||
if hint == "" {
|
||||
hint = defaultHint(typed.Code)
|
||||
}
|
||||
retry := typed.RetryCommand
|
||||
if retry == "" {
|
||||
retry = defaultRetryCommand(typed.Code)
|
||||
}
|
||||
// Build message without the code prefix — the envelope's separate
|
||||
// "type" field already carries the code, so repeating it in "message"
|
||||
// causes agents that render "{type}: {message}" to produce a doubled
|
||||
// prefix (e.g. "resource.not_found: resource.not_found: ...").
|
||||
msg := typed.Message
|
||||
if typed.Cause != nil {
|
||||
msg = fmt.Sprintf("%s: %v", typed.Message, typed.Cause)
|
||||
}
|
||||
detail := &output.ErrDetail{
|
||||
Type: string(typed.Code),
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
RetryCommand: retry,
|
||||
RetryAfterSeconds: typed.RetryAfterSeconds,
|
||||
Detail: typed.Detail,
|
||||
}
|
||||
if typed.Risk != nil {
|
||||
detail.Risk = &output.RiskDetail{Level: typed.Risk.Level, Action: typed.Risk.Action}
|
||||
}
|
||||
return detail
|
||||
}
|
||||
// Cobra parse / arg-count errors flow through cmdutil.NewFlagError —
|
||||
// surface them as input.invalid_argument so the wire envelope carries a
|
||||
// useful typed code instead of the unclassified "internal.error" bucket.
|
||||
// ExitCode separately maps FlagError → 2.
|
||||
var fe *FlagError
|
||||
if errors.As(err, &fe) {
|
||||
return &output.ErrDetail{
|
||||
Type: string(CodeInputInvalidArgument),
|
||||
Message: err.Error(),
|
||||
Hint: defaultHint(CodeInputInvalidArgument),
|
||||
}
|
||||
}
|
||||
return &output.ErrDetail{Type: "internal.error", Message: err.Error()}
|
||||
}
|
||||
|
||||
// NewError constructs a typed error.
|
||||
func NewError(code ErrorCode, message string) *Error {
|
||||
return &Error{Code: code, Message: message}
|
||||
@@ -207,6 +320,15 @@ func matchPrefix(err error, prefix string) bool {
|
||||
return strings.HasPrefix(string(e.Code), prefix)
|
||||
}
|
||||
|
||||
// serverNotFoundRE matches the WeKnora server's structured error-envelope body
|
||||
// for the typed "not found" code (1003 = ErrNotFound). Server's 1007 is the
|
||||
// generic ErrInternalServer bucket — including it would mis-classify every
|
||||
// validation / DB failure (e.g. SQLSTATE 22001 "value too long") as
|
||||
// resource.not_found, sending agents down the wrong recovery path.
|
||||
// Matching the structured "code":1003 anchor avoids the free-substring false
|
||||
// positive (e.g. a stack trace containing "config file not found").
|
||||
var serverNotFoundRE = regexp.MustCompile(`"code":1003\b`)
|
||||
|
||||
// ClassifyHTTPStatus maps an HTTP status code to the canonical ErrorCode.
|
||||
// Single source of truth so error codes stay aligned whether the failure
|
||||
// was detected by the SDK (string-formatted error) or by the CLI directly
|
||||
@@ -257,7 +379,18 @@ func ClassifyHTTPError(err error) ErrorCode {
|
||||
if perr != nil {
|
||||
return CodeServerError
|
||||
}
|
||||
return ClassifyHTTPStatus(status)
|
||||
base := ClassifyHTTPStatus(status)
|
||||
// Server-side 500-misclassification rescue: some servers return HTTP 500
|
||||
// for logical "not found" cases (e.g. code 1007 "knowledge base not found",
|
||||
// code 1003 "Knowledge not found") instead of 404. Match the server's known
|
||||
// error-code envelope precisely to avoid false-positive rescues on generic
|
||||
// 500 bodies that happen to contain "not found" (e.g. "config file not found
|
||||
// in stack trace"). The free-substring match would over-match.
|
||||
body := rest[end+1:]
|
||||
if base == CodeServerError && serverNotFoundRE.MatchString(body) {
|
||||
return CodeResourceNotFound
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// AllCodes returns the registered error code set.
|
||||
@@ -273,12 +406,13 @@ func AllCodes() []ErrorCode {
|
||||
CodeResourceNotFound, CodeResourceAlreadyExists, CodeResourceLocked,
|
||||
// input
|
||||
CodeInputInvalidArgument, CodeInputMissingFlag, CodeInputConfirmationRequired,
|
||||
CodeInputUnknownSubcommand,
|
||||
// server / network
|
||||
CodeServerError, CodeServerTimeout, CodeServerRateLimited,
|
||||
CodeServerIncompatibleVersion, CodeNetworkError,
|
||||
// local
|
||||
CodeLocalConfigCorrupt, CodeLocalKeychainDenied, CodeLocalFileIO,
|
||||
CodeLocalUnimplemented, CodeLocalContextNotFound,
|
||||
CodeLocalUnimplemented, CodeLocalProfileNotFound,
|
||||
CodeKBIDRequired, CodeKBNotFound,
|
||||
CodeProjectLinkCorrupt,
|
||||
CodeUserAborted, CodeUploadFileNotFound,
|
||||
@@ -308,3 +442,17 @@ func ClassifyHTTPErrorOutputs() []ErrorCode {
|
||||
CodeNetworkError, // non-HTTP error
|
||||
}
|
||||
}
|
||||
|
||||
// IsCancelled reports whether err is a context cancellation, either via
|
||||
// the context itself or via wrapped CancelError / context.Canceled /
|
||||
// context.DeadlineExceeded. Used by streaming commands to distinguish
|
||||
// SIGINT-driven shutdown from real errors.
|
||||
func IsCancelled(ctx context.Context, err error) bool {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
if ctx.Err() == context.Canceled {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestErrorToDetail_NilSafe(t *testing.T) {
|
||||
if got := ErrorToDetail(nil); got != nil {
|
||||
t.Errorf("ErrorToDetail(nil) should return nil; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestError_WithRetryCommand(t *testing.T) {
|
||||
err := NewError(CodeAuthUnauthenticated, "session expired").
|
||||
WithHint("run `weknora auth login`").
|
||||
WithRetryCommand("weknora auth login --host https://kb.example.com")
|
||||
|
||||
if err.RetryCommand != "weknora auth login --host https://kb.example.com" {
|
||||
t.Errorf("RetryCommand not set; got %q", err.RetryCommand)
|
||||
}
|
||||
if err.Hint != "run `weknora auth login`" {
|
||||
t.Errorf("Hint changed unexpectedly; got %q", err.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestError_RetryCommand_EmptyByDefault(t *testing.T) {
|
||||
err := NewError(CodeResourceAlreadyExists, "kb name exists")
|
||||
if err.RetryCommand != "" {
|
||||
t.Errorf("RetryCommand should default empty; got %q", err.RetryCommand)
|
||||
}
|
||||
}
|
||||
@@ -34,3 +34,72 @@ func TestClassifyHTTPError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyHTTPError_500NotFoundRescue(t *testing.T) {
|
||||
// Server's 1003 = ErrNotFound is the only typed "not found" code we
|
||||
// rescue. 1007 = ErrInternalServer is the catch-all bucket — its
|
||||
// presence around a "not found"-shaped message reflects a server-side
|
||||
// classification gap, not an authoritative not-found signal, so we
|
||||
// must NOT silently re-route it.
|
||||
err := fmt.Errorf("HTTP error 500: %s", `{"error":{"code":1003,"message":"Knowledge not found"},"success":false}`)
|
||||
if got := ClassifyHTTPError(err); got != CodeResourceNotFound {
|
||||
t.Errorf("expected resource.not_found rescue for code 1003; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyHTTPError_500GenericCode_StaysServerError pins the round-9
|
||||
// finding: code 1007 is server's generic ErrInternalServer bucket
|
||||
// (validation errors, DB failures, etc.), NOT a not-found signal. Even
|
||||
// when its message text contains "not found", rescuing it would mis-route
|
||||
// e.g. a 10k-char KB name (SQLSTATE 22001) as resource.not_found.
|
||||
func TestClassifyHTTPError_500GenericCode_StaysServerError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "code 1007 with 'not found' in message",
|
||||
body: `HTTP error 500: {"error":{"code":1007,"message":"knowledge base not found"},"success":false}`,
|
||||
},
|
||||
{
|
||||
name: "code 1007 with SQLSTATE",
|
||||
body: `HTTP error 500: {"error":{"code":1007,"message":"value too long for type character varying(255) (SQLSTATE 22001)"},"success":false}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := fmt.Errorf("%s", tc.body)
|
||||
if got := ClassifyHTTPError(err); got != CodeServerError {
|
||||
t.Errorf("expected server.error for generic code 1007; got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyHTTPError_500Generic_StaysServerError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "generic 500",
|
||||
body: "HTTP error 500: internal server error",
|
||||
},
|
||||
{
|
||||
name: "config file not found in stack trace",
|
||||
body: "HTTP error 500: panic: config file not found in /etc/app/config.yaml",
|
||||
},
|
||||
{
|
||||
name: "not found substring without server code",
|
||||
body: `HTTP error 500: {"message":"something not found","other":true}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := fmt.Errorf("%s", tc.body)
|
||||
if got := ClassifyHTTPError(err); got != CodeServerError {
|
||||
t.Errorf("expected server.error for generic 500; got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,42 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
)
|
||||
|
||||
// globalFormatMode tracks the resolved --format value for the current invocation.
|
||||
// Set by cmd/root.go in PersistentPreRunE; used by PrintError to choose text vs envelope.
|
||||
var globalFormatMode string
|
||||
|
||||
// SetFormatMode records the resolved --format mode for the current invocation.
|
||||
// Called by cmd/root.go PersistentPreRunE after FormatOptions.ResolveDefault.
|
||||
func SetFormatMode(mode string) {
|
||||
globalFormatMode = mode
|
||||
}
|
||||
|
||||
// globalProfile tracks the resolved profile name for the current invocation.
|
||||
// Set by cmd/root.go in PersistentPreRunE via SetProfile; read by Emit and
|
||||
// init events to populate envelope.profile / NDJSON init.profile.
|
||||
var globalProfile string
|
||||
|
||||
// SetProfile records the resolved profile name for the current invocation.
|
||||
// Called by cmd/root.go PersistentPreRunE after SetFormatMode.
|
||||
func SetProfile(name string) { globalProfile = name }
|
||||
|
||||
// GetProfile returns the profile name recorded for the current invocation.
|
||||
// Empty string when nothing is configured (omitempty fields suppress the field).
|
||||
func GetProfile() string { return globalProfile }
|
||||
|
||||
// ExitCode maps an error to the documented CLI exit code.
|
||||
// - 0 success
|
||||
// - 1 generic / unknown typed error - fallback bucket: resource.already_exists,
|
||||
// resource.locked, local.*, mcp.*, operation.failed, server.session_create_failed
|
||||
// (workflow-level, see special case below), and any code outside the named
|
||||
// buckets below
|
||||
// - 2 flag / argument problem (cobra parse / unknown subcommand)
|
||||
// - 2 cobra-parse problem (unrecognised flag, arg-count violation) —
|
||||
// typed input.unknown_subcommand from the guard maps to exit 5
|
||||
// (input.* bucket); only ungated cobra prose lands here
|
||||
// - 3 auth.*
|
||||
// - 4 resource.not_found
|
||||
// - 5 input.* (other than confirmation_required)
|
||||
@@ -64,14 +91,31 @@ func ExitCode(err error) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
// PrintError writes err to w (typically stderr) as `code: message\nhint:
|
||||
// ...`. Typed *Error values surface their Hint as a second line so users
|
||||
// see the actionable next-step. Falls through to defaultHint when the
|
||||
// caller didn't set one.
|
||||
// PrintError writes err to w (typically stderr) in dual mode:
|
||||
// - text/human: code: msg\nhint: ...\nretry: ...
|
||||
// - json/ndjson: {ok:false, error:{...}, _notice?:...}
|
||||
//
|
||||
// Mode is read from globalFormatMode (set by root PersistentPreRunE).
|
||||
func PrintError(w io.Writer, err error) {
|
||||
if err == nil || errors.Is(err, SilentError) {
|
||||
return
|
||||
}
|
||||
// Typed *Error with Silent=true suppresses stderr emit while preserving
|
||||
// the Code for ExitCode. Used by batch paths that already wrote per-item
|
||||
// detail to stdout (cmdutil.RunBatch) — emitting a summary envelope on
|
||||
// stderr would duplicate the failure signal.
|
||||
if typed := AsError(err); typed != nil && typed.Silent {
|
||||
return
|
||||
}
|
||||
|
||||
if globalFormatMode == "json" || globalFormatMode == "ndjson" {
|
||||
printErrorEnvelope(w, err)
|
||||
return
|
||||
}
|
||||
printErrorProse(w, err)
|
||||
}
|
||||
|
||||
func printErrorProse(w io.Writer, err error) {
|
||||
fmt.Fprintln(w, err.Error())
|
||||
var typed *Error
|
||||
if errors.As(err, &typed) {
|
||||
@@ -82,9 +126,20 @@ func PrintError(w io.Writer, err error) {
|
||||
if hint != "" {
|
||||
fmt.Fprintf(w, "hint: %s\n", hint)
|
||||
}
|
||||
retry := typed.RetryCommand
|
||||
if retry == "" {
|
||||
retry = defaultRetryCommand(typed.Code)
|
||||
}
|
||||
if retry != "" {
|
||||
fmt.Fprintf(w, "retry: %s\n", retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printErrorEnvelope(w io.Writer, err error) {
|
||||
_ = output.WriteErrorEnvelope(w, ErrorToDetail(err), false)
|
||||
}
|
||||
|
||||
// defaultHint returns a canonical actionable hint for known error codes
|
||||
// when the call site didn't set one. `auth.unauthenticated` always points
|
||||
// at `weknora auth login` - covers the broad surface (auth status / kb
|
||||
@@ -98,9 +153,9 @@ func defaultHint(code ErrorCode) string {
|
||||
case CodeAuthTokenExpired:
|
||||
return "your session expired; run `weknora auth login` to re-authenticate"
|
||||
case CodeAuthForbidden:
|
||||
return "active context lacks permission for this resource"
|
||||
return "active profile lacks permission for this resource"
|
||||
case CodeAuthCrossTenantBlocked, CodeAuthTenantMismatch:
|
||||
return "verify tenant context with `weknora auth status`"
|
||||
return "verify tenant profile with `weknora auth status`"
|
||||
case CodeNetworkError:
|
||||
return "check base URL reachability with `weknora doctor`"
|
||||
case CodeServerIncompatibleVersion:
|
||||
@@ -142,3 +197,30 @@ func defaultHint(code ErrorCode) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// defaultRetryCommand returns canonical retry argv for known codes.
|
||||
// Empty string for codes without a stable canonical retry.
|
||||
// Symmetric counterpart to defaultHint.
|
||||
func defaultRetryCommand(code ErrorCode) string {
|
||||
switch code {
|
||||
case CodeAuthUnauthenticated, CodeAuthBadCredential, CodeAuthTokenExpired:
|
||||
return "weknora auth login"
|
||||
case CodeKBIDRequired:
|
||||
return "weknora link"
|
||||
case CodeNetworkError, CodeServerTimeout:
|
||||
return "weknora doctor"
|
||||
case CodeProjectLinkCorrupt:
|
||||
return "weknora link" // 重新绑定
|
||||
case CodeLocalConfigCorrupt:
|
||||
// 删 config + 重 login 是两步;prose hint 已说明,retry argv 留空
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultHint and DefaultRetryCommand are exported wrappers so that
|
||||
// cross-package callers (MCP handlers, batch envelope helpers) can
|
||||
// resolve hint/retry without duplicating the typed-code → string table.
|
||||
// Avoids drift between cmdutil and copies elsewhere.
|
||||
func DefaultHint(code ErrorCode) string { return defaultHint(code) }
|
||||
func DefaultRetryCommand(code ErrorCode) string { return defaultRetryCommand(code) }
|
||||
|
||||
@@ -3,6 +3,7 @@ package cmdutil
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -58,3 +59,65 @@ func TestPrintError(t *testing.T) {
|
||||
assert.Contains(t, buf.String(), "no creds")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrintError_JSONMode_WritesEnvelope(t *testing.T) {
|
||||
t.Cleanup(func() { SetFormatMode("") })
|
||||
SetFormatMode("json")
|
||||
|
||||
err := NewError(CodeInputConfirmationRequired, "kb delete kb_x requires confirmation").
|
||||
WithHint("re-run with -y/--yes").
|
||||
WithRetryCommand("weknora kb delete kb_x -y")
|
||||
|
||||
var buf bytes.Buffer
|
||||
PrintError(&buf, err)
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"ok":false`) {
|
||||
t.Errorf("expected envelope ok:false; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"type":"input.confirmation_required"`) {
|
||||
t.Errorf("expected typed code; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"retry_command":"weknora kb delete kb_x -y"`) {
|
||||
t.Errorf("expected retry_command; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintError_JSONMode_IncludesRetryAfter(t *testing.T) {
|
||||
t.Cleanup(func() { SetFormatMode("") })
|
||||
SetFormatMode("json")
|
||||
|
||||
err := NewError(CodeServerRateLimited, "rate limited").
|
||||
WithRetryAfter(30)
|
||||
|
||||
var buf bytes.Buffer
|
||||
PrintError(&buf, err)
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"retry_after_seconds":30`) {
|
||||
t.Errorf("expected retry_after_seconds:30; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintError_TextMode_WritesProse(t *testing.T) {
|
||||
t.Cleanup(func() { SetFormatMode("") })
|
||||
SetFormatMode("human")
|
||||
|
||||
err := NewError(CodeInputConfirmationRequired, "kb delete kb_x requires confirmation").
|
||||
WithHint("re-run with -y/--yes").
|
||||
WithRetryCommand("weknora kb delete kb_x -y")
|
||||
|
||||
var buf bytes.Buffer
|
||||
PrintError(&buf, err)
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "input.confirmation_required: kb delete kb_x requires confirmation") {
|
||||
t.Errorf("expected prose code:message line; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "hint: re-run with -y/--yes") {
|
||||
t.Errorf("expected hint line; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "retry: weknora kb delete kb_x -y") {
|
||||
t.Errorf("expected retry line; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package output_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWriteEnvelope_SuccessWithData(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
data := map[string]string{"id": "kb_x"}
|
||||
if err := output.WriteEnvelope(&buf, data, nil, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"ok":true`) {
|
||||
t.Errorf("missing ok:true; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"data":{"id":"kb_x"}`) {
|
||||
t.Errorf("missing data; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelope_OmitDataWhenNil(t *testing.T) {
|
||||
// mutation 无 payload 时 data 字段应被省略(omitempty)
|
||||
var buf bytes.Buffer
|
||||
if err := output.WriteEnvelope(&buf, nil, nil, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if strings.Contains(got, `"data"`) {
|
||||
t.Errorf("data field should be omitted when nil; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"ok":true`) {
|
||||
t.Errorf("missing ok:true; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelope_WithMeta(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
meta := &output.Meta{Count: 2, HasMore: false}
|
||||
if err := output.WriteEnvelope(&buf, []string{"a", "b"}, meta, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"meta":{"count":2}`) {
|
||||
// has_more:false should be omitted by omitempty when false
|
||||
t.Errorf("meta unexpected shape; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorEnvelope_FullShape(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
errDetail := &output.ErrDetail{
|
||||
Type: "input.confirmation_required",
|
||||
Message: "kb delete kb_x requires confirmation",
|
||||
Hint: "re-run with -y/--yes",
|
||||
RetryCommand: "weknora kb delete kb_x -y",
|
||||
Risk: &output.RiskDetail{
|
||||
Level: "destructive",
|
||||
Action: "kb.delete",
|
||||
},
|
||||
}
|
||||
if err := output.WriteErrorEnvelope(&buf, errDetail, false); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"ok":false`) {
|
||||
t.Errorf("missing ok:false; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"type":"input.confirmation_required"`) {
|
||||
t.Errorf("missing typed code; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"retry_command":"weknora kb delete kb_x -y"`) {
|
||||
t.Errorf("missing retry_command; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"risk":{"level":"destructive","action":"kb.delete"}`) {
|
||||
t.Errorf("missing risk; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelope_IndentedTTYMode(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := output.WriteEnvelope(&buf, map[string]string{"id": "x"}, nil, true, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "\n \"") {
|
||||
t.Errorf("expected indented multi-line output; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNotice_NilSafe(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
if got := output.GetNotice(); got != nil {
|
||||
t.Errorf("GetNotice with nil PendingNotice should return nil; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNotice_WithSetter(t *testing.T) {
|
||||
output.PendingNotice = func() map[string]any {
|
||||
return map[string]any{"deprecation": "foo is deprecated"}
|
||||
}
|
||||
defer func() { output.PendingNotice = nil }()
|
||||
got := output.GetNotice()
|
||||
if got["deprecation"] != "foo is deprecated" {
|
||||
t.Errorf("notice not populated; got %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user