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
This commit is contained in:
nullkey
2026-05-21 18:40:06 +08:00
committed by lyingbug
parent 7d1fe6f11b
commit 733bb3aaa1
11 changed files with 852 additions and 55 deletions
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
}
}
+54
View File
@@ -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
View File
@@ -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
}
+31
View File
@@ -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)
}
}
+69
View File
@@ -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)
}
})
}
}
+89 -7
View File
@@ -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) }
+63
View File
@@ -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)
}
}
+105
View File
@@ -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)
}
+111
View File
@@ -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)
}
}