WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
package cmdutil
import (
"encoding/json"
"fmt"
"io"
"strings"
"gopkg.in/yaml.v3"
sdk "github.com/Tencent/WeKnora/client"
)
// AgentConfigFlags carries hot-path flag values plus per-flag "was set"
// bits so the merge step can distinguish "user did not pass --foo" from
// "user passed --foo zero-value". Mirrors the canonical CLI pattern of
// keeping presence-tracking out-of-band from the value itself, which
// pflag's Changed() facility supplies at the cobra layer.
type AgentConfigFlags struct {
AgentMode string
AgentModeSet bool
SystemPrompt string
SystemPromptSet bool
ModelID string
ModelIDSet bool
RerankModelID string
RerankModelIDSet bool
Temperature float64
TemperatureSet bool
KBSelectionMode string
KBSelectionModeSet bool
KnowledgeBases []string
KnowledgeBasesSet bool
}
// LoadAgentConfig parses YAML or JSON from r into an AgentConfig. kind is
// "yaml" or "json" (typically inferred from file extension by the caller).
// Returned errors carry no typed code — callers should wrap with
// CodeInputInvalidArgument as the user-facing context.
//
// YAML route round-trips through JSON so the SDK's existing `json:` field
// tags (snake_case) are the single source of truth for key names. yaml.v3
// alone would lowercase field names (KnowledgeBases → knowledgebases),
// which doesn't match the JSON schema users see elsewhere.
func LoadAgentConfig(r io.Reader, kind string) (*sdk.AgentConfig, error) {
body, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var cfg sdk.AgentConfig
switch strings.ToLower(kind) {
case "yaml", "yml":
var raw map[string]any
if err := yaml.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse YAML config: %w", err)
}
jsBody, err := json.Marshal(raw)
if err != nil {
return nil, fmt.Errorf("re-encode YAML as JSON: %w", err)
}
if err := json.Unmarshal(jsBody, &cfg); err != nil {
return nil, fmt.Errorf("parse YAML config (via JSON): %w", err)
}
case "json":
if err := json.Unmarshal(body, &cfg); err != nil {
return nil, fmt.Errorf("parse JSON config: %w", err)
}
default:
return nil, fmt.Errorf("unknown config format %q (want yaml or json)", kind)
}
return &cfg, nil
}
// MergeAgentConfig returns base with hot-path flag overrides applied. Only
// fields whose corresponding *Set bit is true are overridden; the rest of
// base passes through unchanged. The returned pointer is a fresh copy so
// callers can mutate it without aliasing base.
func MergeAgentConfig(base *sdk.AgentConfig, ov AgentConfigFlags) *sdk.AgentConfig {
out := *base // shallow copy
if ov.AgentModeSet {
out.AgentMode = ov.AgentMode
}
if ov.SystemPromptSet {
out.SystemPrompt = ov.SystemPrompt
}
if ov.ModelIDSet {
out.ModelID = ov.ModelID
}
if ov.RerankModelIDSet {
out.RerankModelID = ov.RerankModelID
}
if ov.TemperatureSet {
out.Temperature = ov.Temperature
}
if ov.KBSelectionModeSet {
out.KBSelectionMode = ov.KBSelectionMode
}
if ov.KnowledgeBasesSet {
out.KnowledgeBases = ov.KnowledgeBases
}
return &out
}
// GenerateAgentSkeleton writes a commented YAML template with every
// AgentConfig field at its zero value to w. Used by
// `agent create --generate-skeleton` so users get a ready-to-edit
// starting point without authoring the full schema from memory.
func GenerateAgentSkeleton(w io.Writer) error {
const skeleton = `# WeKnora AgentConfig YAML skeleton
# Edit this file and pass it to:
# weknora agent create "My Agent" --model <id> --config-file <this-file>
# Hot-path flags on the create command override values set here.
# Operating mode: "quick-answer" or "smart-reasoning"
agent_mode: ""
# System prompt for the agent (also settable via --system-prompt[-file])
system_prompt: ""
# Optional template applied to retrieved context before model input
context_template: ""
# REQUIRED: LLM model id (server-side managed); also settable via --model
model_id: ""
# Optional rerank model id (server-side managed); also settable via --rerank-model
rerank_model_id: ""
# Generation tuning
temperature: 0.0
max_completion_tokens: 0
max_iterations: 0
# Tools / MCP integration
allowed_tools: []
mcp_selection_mode: "" # "all" / "selected" / "none"
mcp_services: []
# Knowledge base attachment
kb_selection_mode: "" # "all" / "selected" / "none"; also settable via --kb-selection-mode
knowledge_bases: [] # KB ids; also settable via repeated --kb
supported_file_types: []
# FAQ
faq_priority_enabled: false
faq_direct_answer_threshold: 0.0
faq_score_boost: 0.0
# Web search
web_search_enabled: false
web_search_max_results: 0
# Multi-turn
multi_turn_enabled: false
history_turns: 0
# Retrieval thresholds
embedding_top_k: 0
keyword_threshold: 0.0
vector_threshold: 0.0
rerank_top_k: 0
rerank_threshold: 0.0
# Query understanding / rewrite
enable_query_expansion: false
enable_rewrite: false
rewrite_prompt_system: ""
rewrite_prompt_user: ""
query_understand_model_id: ""
# Fallback when retrieval / generation fails
fallback_strategy: "" # "fixed" or "model"
fallback_response: ""
fallback_prompt: ""
`
_, err := io.WriteString(w, skeleton)
return err
}
+114
View File
@@ -0,0 +1,114 @@
package cmdutil
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sdk "github.com/Tencent/WeKnora/client"
)
func TestLoadConfigFile_YAML(t *testing.T) {
yaml := strings.NewReader(`
agent_mode: smart-reasoning
model_id: model-x
temperature: 0.7
knowledge_bases:
- kb_abc
- kb_def
`)
cfg, err := LoadAgentConfig(yaml, "yaml")
require.NoError(t, err)
assert.Equal(t, "smart-reasoning", cfg.AgentMode)
assert.Equal(t, "model-x", cfg.ModelID)
assert.InDelta(t, 0.7, cfg.Temperature, 0.001)
assert.Equal(t, []string{"kb_abc", "kb_def"}, cfg.KnowledgeBases)
}
func TestLoadConfigFile_JSON(t *testing.T) {
js := strings.NewReader(`{"agent_mode":"quick-answer","model_id":"model-y"}`)
cfg, err := LoadAgentConfig(js, "json")
require.NoError(t, err)
assert.Equal(t, "quick-answer", cfg.AgentMode)
assert.Equal(t, "model-y", cfg.ModelID)
}
func TestLoadConfigFile_UnknownKind(t *testing.T) {
_, err := LoadAgentConfig(strings.NewReader(""), "xml")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown config format")
}
func TestLoadConfigFile_BadYAML(t *testing.T) {
_, err := LoadAgentConfig(strings.NewReader("not: : yaml"), "yaml")
require.Error(t, err)
}
func TestLoadConfigFile_BadJSON(t *testing.T) {
_, err := LoadAgentConfig(strings.NewReader("{not json"), "json")
require.Error(t, err)
}
func TestMergeAgentConfig_FlagsOverrideFile(t *testing.T) {
base := &sdk.AgentConfig{ModelID: "model-y"}
overrides := AgentConfigFlags{ModelIDSet: true, ModelID: "model-x"}
merged := MergeAgentConfig(base, overrides)
assert.Equal(t, "model-x", merged.ModelID)
}
func TestMergeAgentConfig_UnsetFlagsPreserveBase(t *testing.T) {
base := &sdk.AgentConfig{ModelID: "model-y", Temperature: 0.5}
overrides := AgentConfigFlags{} // nothing set
merged := MergeAgentConfig(base, overrides)
assert.Equal(t, "model-y", merged.ModelID)
assert.InDelta(t, 0.5, merged.Temperature, 0.001)
}
func TestMergeAgentConfig_EveryFieldOverlay(t *testing.T) {
base := &sdk.AgentConfig{
AgentMode: "quick-answer",
SystemPrompt: "old",
ModelID: "model-y",
RerankModelID: "rerank-old",
Temperature: 0.1,
KBSelectionMode: "all",
KnowledgeBases: []string{"kb_old"},
}
overrides := AgentConfigFlags{
AgentMode: "smart-reasoning", AgentModeSet: true,
SystemPrompt: "new", SystemPromptSet: true,
ModelID: "model-x", ModelIDSet: true,
RerankModelID: "rerank-new", RerankModelIDSet: true,
Temperature: 0.9, TemperatureSet: true,
KBSelectionMode: "selected", KBSelectionModeSet: true,
KnowledgeBases: []string{"kb_new"}, KnowledgeBasesSet: true,
}
merged := MergeAgentConfig(base, overrides)
assert.Equal(t, "smart-reasoning", merged.AgentMode)
assert.Equal(t, "new", merged.SystemPrompt)
assert.Equal(t, "model-x", merged.ModelID)
assert.Equal(t, "rerank-new", merged.RerankModelID)
assert.InDelta(t, 0.9, merged.Temperature, 0.001)
assert.Equal(t, "selected", merged.KBSelectionMode)
assert.Equal(t, []string{"kb_new"}, merged.KnowledgeBases)
}
func TestGenerateSkeleton_EmitsAllFields(t *testing.T) {
var out strings.Builder
require.NoError(t, GenerateAgentSkeleton(&out))
body := out.String()
for _, field := range []string{
"agent_mode:",
"model_id:",
"system_prompt:",
"knowledge_bases:",
"fallback_strategy:",
"context_template:",
"max_completion_tokens:",
"embedding_top_k:",
} {
assert.Contains(t, body, field, "skeleton missing field %s", field)
}
}
+104
View File
@@ -0,0 +1,104 @@
// Package-level note:
//
// SetAgentHelp wires structured agent-targeted help onto a cobra command.
// Current coverage: chat, kb list, and destructive commands. Adding it to
// another command requires touching only that command's NewCmd (a 5-line
// copy of the existing call sites).
package cmdutil
import (
"encoding/json"
"fmt"
"io"
"os"
"github.com/spf13/cobra"
)
// AgentHelp is the structured help blob emitted when an agent invokes
// `weknora <command> --help` with WEKNORA_AGENT_HELP=1. Distinct from
// cobra's human help text — agent-readable JSON keyed by stable fields
// so an LLM doesn't need to scrape the human help table.
//
// Warnings surface both as a JSON field (agent introspection) and as an
// "AI agents:" block appended to human help. The two channels share a
// single source list so they cannot drift.
type AgentHelp struct {
UsedFor string `json:"used_for"`
RequiredFlags []string `json:"required_flags,omitempty"`
Examples []string `json:"examples,omitempty"`
Output string `json:"output,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// agentHelpAnnotation stores the marshaled AgentHelp on the command so the
// `weknora schema` command can introspect any command's contract without
// invoking its help func (and toggling WEKNORA_AGENT_HELP). Shares the
// annotations map with SetRisk (distinct keys).
const agentHelpAnnotation = "weknora.agent_help"
// SetAgentHelp attaches agent-targeted help metadata to a command.
//
// Routing:
// - WEKNORA_AGENT_HELP=1: emit the AgentHelp JSON blob to stdout and
// return — agents get clean parseable JSON, no trailing prose.
// - Otherwise (human help path): if cmd has risk annotations from SetRisk,
// prepend "Risk: <action> (<level>)" line; then render the normal human
// help via origHelp; then append the "AI agents:" Warnings block.
//
// The AgentHelp is also recorded as a JSON annotation so `weknora schema` can
// surface it as a first-class, discoverable command (not only via the
// WEKNORA_AGENT_HELP env toggle on --help).
func SetAgentHelp(cmd *cobra.Command, ah AgentHelp) {
if b, err := json.Marshal(ah); err == nil {
if cmd.Annotations == nil {
cmd.Annotations = make(map[string]string)
}
cmd.Annotations[agentHelpAnnotation] = string(b)
}
origHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
if os.Getenv("WEKNORA_AGENT_HELP") == "1" {
emitAgentHelp(c.OutOrStdout(), ah)
return
}
// Prepend Risk: line in the default (human) branch only; the
// JSON branch above already carries warnings[]. Skip silently if
// the command has no risk annotations.
if level, action, ok := GetRisk(c); ok {
fmt.Fprintf(c.OutOrStdout(), "Risk: %s (%s)\n\n", action, level)
}
origHelp(c, args)
if len(ah.Warnings) > 0 {
w := c.OutOrStdout()
fmt.Fprintln(w)
fmt.Fprintln(w, "AI agents:")
for _, msg := range ah.Warnings {
fmt.Fprintln(w, "- "+msg)
}
}
})
}
// AgentHelpFor returns the AgentHelp registered on cmd via SetAgentHelp.
// ok is false when the command carries none (e.g. cobra-generated subtrees).
func AgentHelpFor(cmd *cobra.Command) (ah AgentHelp, ok bool) {
if cmd.Annotations == nil {
return ah, false
}
raw, present := cmd.Annotations[agentHelpAnnotation]
if !present {
return ah, false
}
if err := json.Unmarshal([]byte(raw), &ah); err != nil {
return ah, false
}
return ah, true
}
func emitAgentHelp(w io.Writer, ah AgentHelp) {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
_ = enc.Encode(ah)
}
+100
View File
@@ -0,0 +1,100 @@
package cmdutil
import (
"bytes"
"encoding/json"
"testing"
"github.com/spf13/cobra"
)
func TestSetAgentHelp_EmitsJSONWhenEnvSet(t *testing.T) {
t.Setenv("WEKNORA_AGENT_HELP", "1")
cmd := &cobra.Command{Use: "foo"}
ah := AgentHelp{
UsedFor: "frob a bar",
RequiredFlags: []string{"--name"},
Examples: []string{"weknora foo --name=x"},
}
SetAgentHelp(cmd, ah)
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.Help()
var got AgentHelp
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, buf.String())
}
if got.UsedFor != "frob a bar" {
t.Errorf("UsedFor=%q, want %q", got.UsedFor, "frob a bar")
}
if len(got.RequiredFlags) != 1 || got.RequiredFlags[0] != "--name" {
t.Errorf("RequiredFlags=%v", got.RequiredFlags)
}
}
func TestSetAgentHelp_FallsThroughToHumanHelp(t *testing.T) {
t.Setenv("WEKNORA_AGENT_HELP", "")
cmd := &cobra.Command{
Use: "foo",
Short: "frob a bar",
Long: "Detailed human help here.",
}
SetAgentHelp(cmd, AgentHelp{UsedFor: "ignored"})
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.Help()
// Should NOT be JSON (human help is text)
if json.Valid(buf.Bytes()) {
t.Errorf("expected human text, got JSON:\n%s", buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte("Detailed human help")) {
t.Errorf("human help missing from output:\n%s", buf.String())
}
}
func TestSetAgentHelp_AgentJSON_NoTrailingWarningsProse(t *testing.T) {
cmd := &cobra.Command{Use: "kb-delete-test"}
SetAgentHelp(cmd, AgentHelp{
UsedFor: "Delete a knowledge base",
Warnings: []string{"irreversible"},
})
t.Setenv("WEKNORA_AGENT_HELP", "1")
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.HelpFunc()(cmd, nil)
// Should be parseable as a single JSON object with the warnings field.
var got AgentHelp
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("agent-help output is not valid JSON: %v\n%s", err, buf.String())
}
if len(got.Warnings) != 1 || got.Warnings[0] != "irreversible" {
t.Errorf("warnings field missing or wrong: %v", got.Warnings)
}
// Should NOT contain the human-prose "AI agents:" block
if bytes.Contains(buf.Bytes(), []byte("AI agents:")) {
t.Errorf("agent JSON path leaked human prose:\n%s", buf.String())
}
}
func TestSetAgentHelp_HumanHelp_AppendsWarningsBlock(t *testing.T) {
cmd := &cobra.Command{Use: "kb-delete-test", Short: "short"}
SetAgentHelp(cmd, AgentHelp{
UsedFor: "Delete a KB",
Warnings: []string{"irreversible"},
})
// Env unset → human help path
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.HelpFunc()(cmd, nil)
if !bytes.Contains(buf.Bytes(), []byte("AI agents:")) {
t.Errorf("human help should include AI agents: block; got:\n%s", buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte("- irreversible")) {
t.Errorf("human help should list warning; got:\n%s", buf.String())
}
}
+65
View File
@@ -0,0 +1,65 @@
package cmdutil
import (
"context"
"errors"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// Refresher is the narrow SDK surface RefreshAndPersist depends on.
// *sdk.Client satisfies it implicitly; tests inject fakes.
type Refresher interface {
RefreshToken(ctx context.Context, refreshToken string) (*sdk.RefreshTokenResponse, error)
}
// RefreshAndPersist reads the stored refresh token for profileName, exchanges it
// for a new access + refresh pair via refresher, and writes both back to the
// secrets store. Returns the new access token (the refresh is already
// persisted as a side-effect, so callers only need the access value to
// retry the original request).
//
// Single canonical implementation shared by `weknora auth refresh` and the
// AuthRetryTransport's refresh closure - both used to inline the same
// six-step sequence with subtly diverging error wording.
func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refresher, profileName string) (string, error) {
refresh, err := store.Get(profileName, "refresh")
if errors.Is(err, secrets.ErrNotFound) || refresh == "" {
return "", &Error{
Code: CodeAuthTokenExpired,
Message: "refresh token missing for profile " + profileName,
Hint: "run `weknora auth login` to re-authenticate",
}
}
if err != nil {
return "", Wrapf(CodeLocalKeychainDenied, err, "load refresh token")
}
resp, err := refresher.RefreshToken(ctx, refresh)
if err != nil {
// WrapHTTP rather than fixed CodeNetworkError so a refresh
// rejected by the server (401/403) surfaces as auth.token_expired /
// auth.forbidden instead of collapsing to network.error.
return "", WrapHTTP(err, "refresh access token")
}
if resp == nil || !resp.Success || resp.AccessToken == "" || resp.RefreshToken == "" {
msg := "refresh token rejected"
if resp != nil && resp.Message != "" {
msg = "refresh token rejected: " + resp.Message
}
return "", &Error{
Code: CodeAuthTokenExpired,
Message: msg,
Hint: "run `weknora auth login` to re-authenticate",
}
}
if err := store.Set(profileName, "access", resp.AccessToken); err != nil {
return "", Wrapf(CodeLocalKeychainDenied, err, "save access token")
}
if err := store.Set(profileName, "refresh", resp.RefreshToken); err != nil {
return "", Wrapf(CodeLocalKeychainDenied, err, "save refresh token")
}
return resp.AccessToken, nil
}
+136
View File
@@ -0,0 +1,136 @@
package cmdutil
import (
"context"
"io"
"net/http"
"strings"
"sync"
sdk "github.com/Tencent/WeKnora/client"
)
// AuthRetryTransport wraps a base http.RoundTripper with transparent
// recovery from a single 401 response. On a 401 from an authenticated
// (non-/auth/*) endpoint, it invokes refreshFn to obtain a new bearer
// token, replays the original request with the new Authorization header,
// and returns the replayed response.
//
// Behavior:
// - 2xx / 4xx / 5xx other than 401: pass through unchanged.
// - 401 on /api/v1/auth/login or /api/v1/auth/refresh: pass through
// (otherwise a stale refresh token causes infinite recursion).
// - 401 with no initial token configured (api-key profiles): pass through
// - api-key credentials have no refresh semantic.
// - 401 with non-replayable request body (req.GetBody == nil): pass
// through. The SDK always uses bytes.Buffer bodies; this is a safety
// net for hand-built requests.
//
// Concurrency: multiple goroutines may hit 401 simultaneously. The first
// one through acquires the mutex and refreshes; subsequent waiters observe
// the updated currentToken and replay without re-refreshing (singleflight).
//
// The transport intentionally shadows the SDK's bearerToken (set via
// WithBearerToken). After a refresh, the SDK is unaware of the new token -
// the transport's per-request override is the single source of truth for
// the Authorization header. Tokens persisted to the secrets store happen
// inside refreshFn (see cmdutil.RefreshAndPersist).
type AuthRetryTransport struct {
base http.RoundTripper
mu sync.Mutex
currentToken string
refreshFn func(context.Context) (string, error)
}
// NewAuthRetryTransport builds a retry transport. initialToken seeds the
// in-memory token state so the first request's Authorization header
// agrees with whatever the SDK was constructed with.
//
// Pass an empty initialToken to indicate "no bearer credential configured"
// (e.g. an api-key profile). In that mode the transport never invokes
// refreshFn - a 401 is propagated as-is.
func NewAuthRetryTransport(base http.RoundTripper, initialToken string, refreshFn func(context.Context) (string, error)) *AuthRetryTransport {
if base == nil {
base = http.DefaultTransport
}
return &AuthRetryTransport{
base: base,
currentToken: initialToken,
refreshFn: refreshFn,
}
}
// RoundTrip implements http.RoundTripper.
func (t *AuthRetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.mu.Lock()
token := t.currentToken
t.mu.Unlock()
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := t.base.RoundTrip(req)
if err != nil || resp.StatusCode != http.StatusUnauthorized {
return resp, err
}
if token == "" {
return resp, nil
}
if isAuthEndpoint(req.URL.Path) {
return resp, nil
}
if req.Body != nil && req.GetBody == nil {
return resp, nil
}
// Singleflight: re-check whether another goroutine already refreshed.
t.mu.Lock()
if t.currentToken != token {
token = t.currentToken
t.mu.Unlock()
} else {
newToken, refreshErr := t.refreshFn(req.Context())
if refreshErr != nil {
t.mu.Unlock()
drainAndClose(resp)
return nil, refreshErr
}
t.currentToken = newToken
token = newToken
t.mu.Unlock()
}
drainAndClose(resp)
replay := req.Clone(req.Context())
if req.GetBody != nil {
body, gberr := req.GetBody()
if gberr != nil {
return nil, gberr
}
replay.Body = body
}
replay.Header.Set("Authorization", "Bearer "+token)
return t.base.RoundTrip(replay)
}
// isAuthEndpoint reports whether path is one of the auth endpoints where
// 401 retry would either recurse (refresh) or duplicate work (login). The
// path constants are sourced from the SDK so the CLI and SDK can never
// drift on the canonical paths.
func isAuthEndpoint(path string) bool {
return strings.HasPrefix(path, sdk.PathAuthLogin) ||
strings.HasPrefix(path, sdk.PathAuthRefresh)
}
// drainAndClose consumes the response body so the underlying connection
// can be reused, then closes it. Errors are ignored because the response
// is being discarded anyway.
func drainAndClose(resp *http.Response) {
if resp == nil || resp.Body == nil {
return
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
+270
View File
@@ -0,0 +1,270 @@
package cmdutil
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
)
// stubTransport returns a scripted sequence of responses (and optional
// errors). The N-th request gets the N-th entry; running off the end is a
// test failure.
type stubTransport struct {
t *testing.T
resps []*http.Response
errs []error
bodies []string // captured body of each request, for assertions
authz []string // captured Authorization header of each request
paths []string // captured req URL paths
idx atomic.Int32
}
func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) {
i := s.idx.Add(1) - 1
if int(i) >= len(s.resps) {
s.t.Fatalf("stubTransport: more requests than scripted (req #%d, scripted %d)", i+1, len(s.resps))
}
// Capture body. http.NewRequest with bytes.Buffer body sets GetBody, so
// req.Body is readable each call.
var bodyStr string
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
bodyStr = string(b)
_ = req.Body.Close()
}
s.bodies = append(s.bodies, bodyStr)
s.authz = append(s.authz, req.Header.Get("Authorization"))
s.paths = append(s.paths, req.URL.Path)
var err error
if int(i) < len(s.errs) {
err = s.errs[i]
}
return s.resps[i], err
}
func resp(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
// newReq builds a POST with a replayable body so retries can be verified.
func newReq(t *testing.T, method, url, body string) *http.Request {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = bytes.NewBufferString(body)
}
req, err := http.NewRequest(method, url, rdr)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer old-token")
return req
}
func TestAuthRetry_PassThrough_200(t *testing.T) {
base := &stubTransport{t: t, resps: []*http.Response{resp(200, "ok")}}
rt := NewAuthRetryTransport(base, "old-token", func(context.Context) (string, error) {
t.Fatal("refresh must NOT be called on 200 response")
return "", nil
})
r, err := rt.RoundTrip(newReq(t, "GET", "http://example.test/api/v1/kbs", ""))
if err != nil {
t.Fatalf("roundtrip: %v", err)
}
if r.StatusCode != 200 {
t.Errorf("status=%d, want 200", r.StatusCode)
}
}
func TestAuthRetry_PassThrough_NonAuthError(t *testing.T) {
base := &stubTransport{t: t, resps: []*http.Response{resp(500, "boom")}}
rt := NewAuthRetryTransport(base, "old-token", func(context.Context) (string, error) {
t.Fatal("refresh must NOT be called on non-401 errors")
return "", nil
})
r, _ := rt.RoundTrip(newReq(t, "GET", "http://example.test/api/v1/kbs", ""))
if r.StatusCode != 500 {
t.Errorf("status=%d, want 500", r.StatusCode)
}
}
func TestAuthRetry_401_RefreshAndReplay(t *testing.T) {
base := &stubTransport{t: t,
resps: []*http.Response{resp(401, "expired"), resp(200, "ok-after-retry")},
}
refreshCalls := atomic.Int32{}
rt := NewAuthRetryTransport(base, "old-token", func(context.Context) (string, error) {
refreshCalls.Add(1)
return "new-token", nil
})
r, err := rt.RoundTrip(newReq(t, "POST", "http://example.test/api/v1/sessions", `{"q":"hi"}`))
if err != nil {
t.Fatalf("roundtrip: %v", err)
}
if r.StatusCode != 200 {
t.Errorf("status=%d, want 200", r.StatusCode)
}
if refreshCalls.Load() != 1 {
t.Errorf("refresh called %d times, want 1", refreshCalls.Load())
}
if len(base.authz) != 2 {
t.Fatalf("expected 2 requests through base, got %d", len(base.authz))
}
if base.authz[0] != "Bearer old-token" {
t.Errorf("first req Authorization=%q, want old-token", base.authz[0])
}
if base.authz[1] != "Bearer new-token" {
t.Errorf("replay Authorization=%q, want new-token", base.authz[1])
}
if base.bodies[1] != `{"q":"hi"}` {
t.Errorf("replay body=%q, want original", base.bodies[1])
}
}
func TestAuthRetry_401_RefreshFails(t *testing.T) {
base := &stubTransport{t: t, resps: []*http.Response{resp(401, "expired")}}
rt := NewAuthRetryTransport(base, "old-token", func(context.Context) (string, error) {
return "", errors.New("refresh rejected")
})
_, err := rt.RoundTrip(newReq(t, "GET", "http://example.test/api/v1/kbs", ""))
if err == nil {
t.Fatal("expected refresh error")
}
if !strings.Contains(err.Error(), "refresh rejected") {
t.Errorf("error should surface refresh failure, got %q", err.Error())
}
}
func TestAuthRetry_SkipAuthEndpoints(t *testing.T) {
cases := []string{
"/api/v1/auth/login",
"/api/v1/auth/refresh",
}
for _, path := range cases {
t.Run(path, func(t *testing.T) {
base := &stubTransport{t: t, resps: []*http.Response{resp(401, "")}}
rt := NewAuthRetryTransport(base, "old-token", func(context.Context) (string, error) {
t.Fatalf("refresh must NOT be triggered by a 401 on %s", path)
return "", nil
})
r, _ := rt.RoundTrip(newReq(t, "POST", "http://example.test"+path, ""))
if r.StatusCode != 401 {
t.Errorf("status=%d, want 401 (passthrough)", r.StatusCode)
}
})
}
}
func TestAuthRetry_NoTokenConfigured_DoesNotInjectAuthz(t *testing.T) {
// API-key contexts construct the transport with an empty initial token;
// authretry should pass through 401s untouched (no refresh callback exists
// for api-key - they're rejected at the auth-refresh layer).
base := &stubTransport{t: t, resps: []*http.Response{resp(401, "")}}
refreshed := false
rt := NewAuthRetryTransport(base, "", func(context.Context) (string, error) {
refreshed = true
return "", nil
})
req, _ := http.NewRequest("GET", "http://example.test/api/v1/kbs", nil)
r, _ := rt.RoundTrip(req)
if r.StatusCode != 401 {
t.Errorf("status=%d, want 401", r.StatusCode)
}
if refreshed {
t.Errorf("must not call refresh when initial token is empty (api-key context)")
}
}
func TestAuthRetry_ConcurrentRefresh_SingleFlight(t *testing.T) {
// 4 parallel 401s should trigger exactly 1 refresh; all 4 then retry with
// the new token. Use a server that 401s first request from each unique
// path then 200s on retry - except we use one shared transport with
// scripted responses, ordering matters. To keep determinism, build a
// transport that always returns 401 on first call per request, 200 on
// second, and gate the refresh callback.
var (
refreshCalls atomic.Int32
wg sync.WaitGroup
)
// We'll use a single mux'd base that counts 401-then-200 per goroutine
// via an httptest server, which is the cleanest model for concurrency.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "Bearer new-token" {
_, _ = w.Write([]byte("ok"))
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
rt := NewAuthRetryTransport(http.DefaultTransport, "old-token", func(context.Context) (string, error) {
refreshCalls.Add(1)
return "new-token", nil
})
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
req, _ := http.NewRequest("GET", server.URL+"/api/v1/x", nil)
req.Header.Set("Authorization", "Bearer old-token")
r, err := rt.RoundTrip(req)
if err != nil {
t.Errorf("roundtrip: %v", err)
return
}
if r.StatusCode != 200 {
t.Errorf("status=%d, want 200", r.StatusCode)
}
_ = r.Body.Close()
}()
}
wg.Wait()
if got := refreshCalls.Load(); got != 1 {
t.Errorf("refresh called %d times, want 1 (singleflight)", got)
}
}
func TestAuthRetry_NonReplayableBody_NoRetry(t *testing.T) {
// io.Reader that isn't *bytes.Buffer / Reader / strings.Reader → http
// does NOT set req.GetBody. Confirm we don't lose the response and
// don't attempt a retry.
base := &stubTransport{t: t, resps: []*http.Response{resp(401, "")}}
rt := NewAuthRetryTransport(base, "old-token", func(context.Context) (string, error) {
t.Fatal("must not retry without GetBody")
return "", nil
})
// Use a custom Reader the stdlib won't recognize.
type unknownReader struct{ io.Reader }
body := &unknownReader{Reader: strings.NewReader("once")}
req, _ := http.NewRequest("POST", "http://example.test/api/v1/kbs", body)
req.Header.Set("Authorization", "Bearer old-token")
// Sanity: GetBody should be nil for this body type.
if req.GetBody != nil {
t.Fatalf("test premise failed: GetBody is set for an unknownReader body")
}
r, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("roundtrip: %v", err)
}
if r.StatusCode != 401 {
t.Errorf("status=%d, want 401 (passthrough)", r.StatusCode)
}
}
+138
View File
@@ -0,0 +1,138 @@
// Package cmdutil — batch.go provides reusable plumbing for multi-target
// mutations that emit the batch envelope.
//
// Three pieces:
// - BatchOutcome: per-target structured outcome (preserves argv order).
// - RunBatch: drives a slice of targets through a closure, collecting
// outcomes. Stops on context cancellation; keep-going on per-item err.
// - EmitBatch: renders the outcomes — JSON/NDJSON via output.WriteBatchEnvelope,
// text via per-item "OK <id>" / "FAIL <id>: <msg>".
package cmdutil
import (
"context"
"errors"
"fmt"
"io"
"time"
"github.com/Tencent/WeKnora/cli/internal/output"
)
// BatchOutcome is one per-target result preserving argv order. Err==nil ⇒ success.
type BatchOutcome struct {
ID string
Err error
}
// RunBatch invokes op(ctx, id) for each id and collects outcomes in order.
// Per-item errors do not abort the run; context cancellation does (the
// remainder of the slice is marked with the context error).
//
// Returns (outcomes, summaryErr). summaryErr is a typed *Error with
// CodeOperationFailed when any per-item op failed, nil otherwise. Callers
// emit the outcomes before returning summaryErr so partial-success data
// reaches stdout.
func RunBatch(ctx context.Context, ids []string, op func(context.Context, string) error) ([]BatchOutcome, error) {
outcomes := make([]BatchOutcome, 0, len(ids))
failed := 0
for _, id := range ids {
select {
case <-ctx.Done():
// Classify the context signal so the per-item envelope reports
// operation.cancelled / operation.timeout (ClassifyContextErr)
// instead of the generic internal.error, and an all-aborted batch
// exits by that class. Cause preserved so errors.Is still matches.
outcomes = append(outcomes, BatchOutcome{ID: id, Err: Wrapf(ClassifyContextErr(ctx.Err()), ctx.Err(), "operation on %s aborted", id)})
failed++
continue
default:
}
err := op(ctx, id)
outcomes = append(outcomes, BatchOutcome{ID: id, Err: err})
if err != nil {
failed++
}
}
if failed > 0 {
// Any failure → operation.failed (exit 1). The aggregate code is
// deliberately coarse: the per-item batch envelope already carries each
// item's typed error (type + exit_code via ErrorToDetail), which is the
// authoritative per-item signal an agent should branch on. Collapsing
// "all failed with the same class" into that class was extra machinery
// for a convenience the per-item data already provides.
return outcomes, &Error{
Code: CodeOperationFailed,
Message: fmt.Sprintf("%d/%d operation(s) failed", failed, len(ids)),
// Silent suppresses the stderr error envelope because the caller
// already emitted the batch envelope to stdout. The exit code
// still propagates via Error.Code → ExitCode.
Silent: true,
}
}
return outcomes, nil
}
// EmitBatch writes the per-item outcomes per --format. JSON/NDJSON emit
// the batch envelope; text mode emits per-line "OK <id>" /
// "FAIL <id>: <msg>".
//
// resultFn builds the per-item Result map for successes; nil ⇒ omit.
// A typical resultFn returns map[string]any{"deleted_at": time.Now()....}.
//
// Callers wanting a stable per-item timestamp pass time.Now() via
// resultFn so tests can pin the clock with SetDeletedAtClock.
func EmitBatch(outcomes []BatchOutcome, fopts *FormatOptions, w io.Writer, resultFn func(id string) any) error {
if fopts.WantsJSON() {
items := make([]output.BatchItem, len(outcomes))
for i, o := range outcomes {
items[i] = output.BatchItem{ID: o.ID, OK: o.Err == nil}
if o.Err != nil {
items[i].Error = ErrorToDetail(o.Err)
} else if resultFn != nil {
items[i].Result = resultFn(o.ID)
}
}
return output.WriteBatchEnvelope(w, items, fopts.TTY, globalProfile)
}
for _, o := range outcomes {
if o.Err == nil {
fmt.Fprintf(w, "OK %s\n", o.ID)
continue
}
fmt.Fprintf(w, "FAIL %s: %s\n", o.ID, o.Err)
}
return nil
}
// deletedAtClock is the clock source for DeletedAtNow. Overridable in tests
// to make per-item timestamps deterministic.
var deletedAtClock = time.Now
// DeletedAtNow is the canonical resultFn for delete batches that report
// {deleted_at: <RFC3339>} per success. Tests can freeze the clock via
// SetDeletedAtClock(func() time.Time { return fixed }).
func DeletedAtNow(id string) any {
return map[string]any{"deleted_at": deletedAtClock().Format(time.RFC3339)}
}
// SetDeletedAtClock overrides the time source used by DeletedAtNow. Returns
// a cleanup func that restores time.Now. For test use only.
func SetDeletedAtClock(clock func() time.Time) func() {
deletedAtClock = clock
return func() { deletedAtClock = time.Now }
}
// ClassifyContextErr maps a context error to the appropriate operation code.
// Falls back to CodeOperationFailed when err is nil or not a context signal.
func ClassifyContextErr(err error) ErrorCode {
switch {
case errors.Is(err, context.Canceled):
return CodeOperationCancelled
case errors.Is(err, context.DeadlineExceeded):
return CodeOperationTimeout
default:
return CodeOperationFailed
}
}
+253
View File
@@ -0,0 +1,253 @@
package cmdutil
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
)
// TestRunBatch_AllSuccess verifies that 3 ids all succeed: outcomes are
// ordered and summaryErr is nil.
func TestRunBatch_AllSuccess(t *testing.T) {
ids := []string{"a", "b", "c"}
op := func(_ context.Context, id string) error { return nil }
outcomes, err := RunBatch(context.Background(), ids, op)
if err != nil {
t.Fatalf("expected nil summaryErr; got %v", err)
}
if len(outcomes) != 3 {
t.Fatalf("expected 3 outcomes; got %d", len(outcomes))
}
for i, o := range outcomes {
if o.ID != ids[i] {
t.Errorf("outcomes[%d].ID = %q, want %q", i, o.ID, ids[i])
}
if o.Err != nil {
t.Errorf("outcomes[%d].Err = %v, want nil", i, o.Err)
}
}
}
// TestRunBatch_PartialFailure verifies that one failing id yields summaryErr
// with CodeOperationFailed while successful outcomes are preserved.
func TestRunBatch_PartialFailure(t *testing.T) {
ids := []string{"ok1", "fail", "ok2"}
errFail := errors.New("something went wrong")
op := func(_ context.Context, id string) error {
if id == "fail" {
return errFail
}
return nil
}
outcomes, summaryErr := RunBatch(context.Background(), ids, op)
if summaryErr == nil {
t.Fatal("expected non-nil summaryErr")
}
typedErr := AsError(summaryErr)
if typedErr == nil {
t.Fatalf("summaryErr is not *Error; got %T %v", summaryErr, summaryErr)
}
if typedErr.Code != CodeOperationFailed {
t.Errorf("summaryErr.Code = %q, want %q", typedErr.Code, CodeOperationFailed)
}
if !strings.Contains(typedErr.Message, "1/3") {
t.Errorf("summaryErr.Message = %q, expected 1/3 ratio", typedErr.Message)
}
if len(outcomes) != 3 {
t.Fatalf("expected 3 outcomes; got %d", len(outcomes))
}
if outcomes[1].Err != errFail {
t.Errorf("outcomes[1].Err = %v, want %v", outcomes[1].Err, errFail)
}
if outcomes[0].Err != nil || outcomes[2].Err != nil {
t.Error("expected outcomes[0] and outcomes[2] to have nil Err")
}
}
// TestRunBatch_StatusExitTriState verifies the batch tri-state exit mapping:
// all-success → exit 0 (nil summaryErr), partial → exit 1, all-fail → exit 1
// (any failure collapses to operation.failed). Pairs with the envelope-status
// tri-state in output.TestWriteBatchEnvelope_StatusTriState.
func TestRunBatch_StatusExitTriState(t *testing.T) {
failIf := func(fails map[string]bool) func(context.Context, string) error {
return func(_ context.Context, id string) error {
if fails[id] {
return errors.New("boom")
}
return nil
}
}
cases := []struct {
name string
ids []string
fails map[string]bool
wantExit int
}{
{"all_success", []string{"a", "b"}, nil, 0},
{"partial", []string{"a", "b"}, map[string]bool{"b": true}, 1},
{"all_fail", []string{"a", "b"}, map[string]bool{"a": true, "b": true}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, summaryErr := RunBatch(context.Background(), tc.ids, failIf(tc.fails))
if got := ExitCode(summaryErr); got != tc.wantExit {
t.Errorf("ExitCode = %d, want %d (summaryErr=%v)", got, tc.wantExit, summaryErr)
}
})
}
}
// TestRunBatch_ContextCancellation verifies that once the context is cancelled,
// remaining ids are marked with the context error without calling op.
func TestRunBatch_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
ids := []string{"first", "second", "third"}
opCalled := 0
op := func(ctx context.Context, id string) error {
opCalled++
if id == "first" {
cancel() // cancel after the first item
}
return nil
}
outcomes, summaryErr := RunBatch(ctx, ids, op)
// summaryErr must be non-nil (cancelled items counted as failed)
if summaryErr == nil {
t.Fatal("expected non-nil summaryErr due to cancellation")
}
if len(outcomes) != 3 {
t.Fatalf("expected 3 outcomes; got %d", len(outcomes))
}
// "second" and "third" should have ctx.Err() as their error
for _, id := range []string{"second", "third"} {
var found *BatchOutcome
for i := range outcomes {
if outcomes[i].ID == id {
found = &outcomes[i]
break
}
}
if found == nil {
t.Fatalf("missing outcome for id %q", id)
}
if !errors.Is(found.Err, context.Canceled) {
t.Errorf("outcome[%s].Err = %v, want context.Canceled", id, found.Err)
}
}
}
// TestEmitBatch_JSON_Envelope verifies that the JSON path emits a valid
// batch envelope with correct ok/error/result fields.
func TestEmitBatch_JSON_Envelope(t *testing.T) {
outcomes := []BatchOutcome{
{ID: "x", Err: nil},
{ID: "y", Err: NewError(CodeResourceNotFound, "not found")},
}
fopts := &FormatOptions{Mode: FormatJSON, TTY: false}
var buf bytes.Buffer
err := EmitBatch(outcomes, fopts, &buf, func(id string) any {
return map[string]any{"deleted_at": "2026-01-01T00:00:00Z"}
})
if err != nil {
t.Fatalf("EmitBatch error: %v", err)
}
got := buf.String()
if !strings.Contains(got, `"ok":true`) {
t.Errorf("expected ok:true in envelope; got %q", got)
}
if !strings.Contains(got, `"id":"x"`) {
t.Errorf("expected id:x; got %q", got)
}
if !strings.Contains(got, `"id":"y"`) {
t.Errorf("expected id:y; got %q", got)
}
if !strings.Contains(got, `"deleted_at":"2026-01-01T00:00:00Z"`) {
t.Errorf("expected result.deleted_at for x; got %q", got)
}
if !strings.Contains(got, `"type":"resource.not_found"`) {
t.Errorf("expected error.type for y; got %q", got)
}
// meta.failures should be 1
if !strings.Contains(got, `"failures":1`) {
t.Errorf("expected meta.failures:1; got %q", got)
}
}
// TestDeletedAtNow_FixedClock verifies that SetDeletedAtClock overrides the
// timestamp used by DeletedAtNow, making per-item values deterministic in tests.
func TestDeletedAtNow_FixedClock(t *testing.T) {
fixed := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)
defer SetDeletedAtClock(func() time.Time { return fixed })()
got := DeletedAtNow("irrelevant")
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("DeletedAtNow returned %T, want map[string]any", got)
}
want := fixed.Format(time.RFC3339)
if m["deleted_at"] != want {
t.Errorf("deleted_at = %q, want %q", m["deleted_at"], want)
}
}
// TestEmitBatch_Text_PerLine verifies that the human/text path emits
// "OK <id>" / "FAIL <id>: <msg>" per line.
func TestEmitBatch_Text_PerLine(t *testing.T) {
outcomes := []BatchOutcome{
{ID: "x", Err: nil},
{ID: "y", Err: fmt.Errorf("boom")},
}
fopts := &FormatOptions{Mode: FormatText}
var buf bytes.Buffer
err := EmitBatch(outcomes, fopts, &buf, nil)
if err != nil {
t.Fatalf("EmitBatch error: %v", err)
}
got := buf.String()
if !strings.Contains(got, "OK x\n") {
t.Errorf("expected 'OK x' line; got %q", got)
}
if !strings.Contains(got, "FAIL y: boom\n") {
t.Errorf("expected 'FAIL y: boom' line; got %q", got)
}
}
// TestRunBatch_AllFailExit1 - any batch failure (partial OR all) collapses to
// operation.failed → exit 1. The authoritative per-item detail lives in the
// batch envelope (each item's typed error); the aggregate exit code is
// deliberately coarse.
func TestRunBatch_AllFailExit1(t *testing.T) {
notFound := func(_ context.Context, id string) error {
return NewError(CodeResourceNotFound, "no such thing "+id)
}
_, summaryErr := RunBatch(context.Background(), []string{"a", "b"}, notFound)
if got := ExitCode(summaryErr); got != 1 {
t.Errorf("all-fail batch ExitCode = %d, want 1; err=%v", got, summaryErr)
}
}
// TestRunBatch_ContextErrorsClassifiedPerItem verifies per-item context errors
// are classified as operation.cancelled / operation.timeout in the batch
// envelope (not the generic internal.error), so an agent inspecting the
// per-item results sees why each item aborted. (The aggregate exit stays 1.)
func TestRunBatch_ContextErrorsClassifiedPerItem(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-cancel so every item takes the ctx.Done branch
outcomes, _ := RunBatch(ctx, []string{"a"}, func(context.Context, string) error { return nil })
if got := ErrorToDetail(outcomes[0].Err).Type; got != string(CodeOperationCancelled) {
t.Errorf("per-item type = %q, want %q", got, CodeOperationCancelled)
}
}
+113
View File
@@ -0,0 +1,113 @@
package cmdutil
import (
"fmt"
"strings"
"github.com/spf13/pflag"
)
// unsafeRune reports whether r is a control / formatting character that has no
// place in a CLI-supplied value: C0 control codes (except the ordinary
// whitespace \t \n \r), DEL, Unicode Bidi overrides/isolates, and zero-width
// characters.
//
// These are rejected because CLI arguments are untrusted — in an agent-first
// CLI they frequently originate from an AI agent or upstream content. A
// smuggled ANSI escape (ESC, U+001B) or Bidi override stored in a resource
// name would execute in, or visually mislead, a human's terminal the moment
// the value is rendered by a later `list` / `view`. Blocking at the input
// boundary mirrors the input-hygiene stance of other agent-first CLIs.
func unsafeRune(r rune) bool {
switch {
case r == '\t' || r == '\n' || r == '\r':
return false // ordinary whitespace is allowed (multi-line --text etc.)
case r < 0x20 || r == 0x7f:
return true // C0 controls (includes ESC U+001B) and DEL
case r >= 0x202a && r <= 0x202e:
return true // LRE RLE PDF LRO RLO — Bidi overrides
case r >= 0x2066 && r <= 0x2069:
return true // LRI RLI FSI PDI — Bidi isolates
case r == 0x200b || r == 0x200c || r == 0x200d || r == 0xfeff:
return true // zero-width space / non-joiner / joiner / BOM
}
return false
}
// CheckSafeText returns an input.invalid_argument error if value contains a
// control / Bidi / zero-width character (see unsafeRune). label names the
// offending input so an agent can fix the right argument. Returns nil for
// ordinary text (including multi-line content with \t \n \r).
func CheckSafeText(value, label string) error {
for i, r := range value {
if unsafeRune(r) {
return &Error{
Code: CodeInputInvalidArgument,
Message: fmt.Sprintf(
"%s contains a disallowed control character (U+%04X at byte %d); strip control / ANSI-escape / Bidi / zero-width characters",
label, r, i,
),
}
}
}
return nil
}
// CheckSafeArgs is the command-boundary input gate, hooked into the root
// PersistentPreRunE so every command inherits it without per-command wiring.
// It enforces two independent rules, each in its own helper so the rule is
// discoverable:
//
// - checkNonEmptyArgs: every positional argument must be non-empty.
// - CheckSafeText: no positional argument or explicitly-set flag value may
// carry control / ANSI / Bidi / zero-width characters.
//
// Default (unchanged) flag values are not scanned — only what the caller
// actually supplied.
func CheckSafeArgs(args []string, flags *pflag.FlagSet) error {
if err := checkNonEmptyArgs(args); err != nil {
return err
}
for i, a := range args {
if err := CheckSafeText(a, fmt.Sprintf("argument %d", i+1)); err != nil {
return err
}
}
if flags == nil {
return nil
}
var ferr error
flags.Visit(func(f *pflag.Flag) {
if ferr == nil {
ferr = CheckSafeText(f.Value.String(), "--"+f.Name)
}
})
return ferr
}
// checkNonEmptyArgs rejects empty / whitespace-only positional arguments before
// any command logic runs — including the dry-run and exit-10 confirmation gates.
// Every positional in this CLI is an id, name, query, or path; none is
// meaningfully empty. The common agent trigger is capturing an id from a failed
// prior step (KB=$(weknora kb create … --jq .data.id) when the create failed →
// ""), which otherwise reaches the server as an empty path segment: GET/DELETE
// /resource/ falls through to the LIST route, the array response fails to
// unmarshal into a single-object struct, and the failure is mis-reported as
// network.error ("check base URL"). Worse, an empty-id delete reaches the
// confirmation gate with a malformed `weknora kb delete -y` retry_argv.
// Caught here as a clear input.invalid_argument (exit 5) instead.
//
// This is intentionally a global invariant (no command exempts itself); flag
// VALUES are not covered, so `--description ""` still legitimately clears a field.
func checkNonEmptyArgs(args []string) error {
for i, a := range args {
if strings.TrimSpace(a) == "" {
return &Error{
Code: CodeInputInvalidArgument,
Message: fmt.Sprintf("argument %d is empty", i+1),
Hint: "an empty id / name / query is never valid — verify the value you captured (e.g. from a prior --jq) is non-empty",
}
}
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package cmdutil
import (
"errors"
"testing"
"github.com/spf13/pflag"
)
func TestCheckSafeText(t *testing.T) {
cases := []struct {
name string
value string
wantErr bool
}{
{"plain ascii", "my-knowledge-base", false},
{"unicode letters", "测试知识库 café", false},
{"emoji", "docs 🔥", false},
{"multiline content allowed", "line1\nline2\twith tab\r\n", false},
{"ansi escape rejected", "inject\x1b[31mRED", true},
{"bell rejected", "name\x07bell", true},
{"null byte rejected", "a\x00b", true},
{"DEL rejected", "a\x7fb", true},
{"bidi override rejected", "abc\u202ervd", true},
{"zero-width rejected", "ab\u200bc", true},
{"BOM rejected", "\ufeffname", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := CheckSafeText(tc.value, "name")
if tc.wantErr && err == nil {
t.Errorf("expected rejection for %q, got nil", tc.value)
}
if !tc.wantErr && err != nil {
t.Errorf("expected %q to be allowed, got %v", tc.value, err)
}
if tc.wantErr {
var typed *Error
if !errors.As(err, &typed) || typed.Code != CodeInputInvalidArgument {
t.Errorf("want *Error with CodeInputInvalidArgument, got %v", err)
}
}
})
}
}
func TestCheckSafeArgs_PositionalAndFlags(t *testing.T) {
fs := pflag.NewFlagSet("t", pflag.ContinueOnError)
name := fs.String("name", "", "")
_ = fs.Parse([]string{"--name", "clean-name"})
// Clean positional + clean flag → ok.
if err := CheckSafeArgs([]string{"good-arg"}, fs); err != nil {
t.Errorf("clean args should pass, got %v", err)
}
// Dirty positional arg → rejected, names the argument.
if err := CheckSafeArgs([]string{"bad\x1b[0marg"}, fs); err == nil {
t.Error("control char in positional arg must be rejected")
}
// Dirty flag value → rejected.
*name = "x" // touch to avoid unused
fs2 := pflag.NewFlagSet("t2", pflag.ContinueOnError)
fs2.String("title", "", "")
_ = fs2.Parse([]string{"--title", "evil\x07"})
if err := CheckSafeArgs(nil, fs2); err == nil {
t.Error("control char in flag value must be rejected")
}
}
// TestCheckSafeArgs_RejectsEmptyPositional pins that empty / whitespace-only
// positional args are rejected as input.invalid_argument before any command
// logic — the common agent failure of capturing an id from a failed prior step
// (KB=$(... --jq .data.id) → "") must surface clearly, not reach the server as
// an empty path segment (mis-classified network.error) or a malformed
// `delete -y` confirmation. Empty FLAG values stay allowed (--description ""
// legitimately clears a field).
func TestCheckSafeArgs_RejectsEmptyPositional(t *testing.T) {
for _, arg := range []string{"", " ", "\t", "\n"} {
err := CheckSafeArgs([]string{arg}, nil)
var ce *Error
if !errors.As(err, &ce) || ce.Code != CodeInputInvalidArgument {
t.Errorf("empty positional %q must be rejected as input.invalid_argument, got %v", arg, err)
}
}
// A non-empty positional alongside an empty one still trips on the empty.
if err := CheckSafeArgs([]string{"ok", ""}, nil); err == nil {
t.Error("a later empty positional must be rejected")
}
// Empty flag value is NOT rejected (clearing a field is valid).
fs := pflag.NewFlagSet("t", pflag.ContinueOnError)
fs.String("description", "", "")
_ = fs.Parse([]string{"--description", ""})
if err := CheckSafeArgs([]string{"id1"}, fs); err != nil {
t.Errorf("empty flag value must stay allowed, got %v", err)
}
}
// TestCheckSafeArgs_UnchangedFlagsNotScanned pins that default (unset) flag
// values are not scanned — only what the caller supplied.
func TestCheckSafeArgs_UnchangedFlagsNotScanned(t *testing.T) {
fs := pflag.NewFlagSet("t", pflag.ContinueOnError)
// A default that (hypothetically) contains a control char must not trip
// the check when the user never set the flag.
fs.String("weird", "default\x1bvalue", "")
_ = fs.Parse(nil)
if err := CheckSafeArgs(nil, fs); err != nil {
t.Errorf("unset flag default must not be scanned, got %v", err)
}
}
+168
View File
@@ -0,0 +1,168 @@
package cmdutil
import (
"fmt"
"unicode"
"unicode/utf8"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// BuildRetryArgv assembles the directly-executable retry argv for a
// confirmation-gated command: head (e.g. []string{"weknora","kb","update",id})
// followed by each *changed* flag named in allow (as "--name", "value") in
// pflag's visit order, plus a trailing "-y".
//
// Slice-typed flags (StringSlice / StringArray) expand to repeated
// "--name", "value" pairs so the argv is re-executable by shells and agents.
// Scalar flags use f.Value.String(). Flags not in allow are skipped (callers
// still exclude secrets / stdin-only values such as --api-key-stdin).
// Replaces the per-command build*RetryCmd helpers.
func BuildRetryArgv(c *cobra.Command, head []string, allow ...string) []string {
allowed := make(map[string]struct{}, len(allow))
for _, a := range allow {
allowed[a] = struct{}{}
}
parts := append([]string{}, head...)
c.Flags().Visit(func(f *pflag.Flag) {
if _, ok := allowed[f.Name]; !ok {
return
}
// Expand multi-value flags as repeated --flag value pairs so retry_argv
// is directly executable (pflag's String() returns "[a,b]", which is not).
if sv, ok := f.Value.(pflag.SliceValue); ok {
for _, v := range sv.GetSlice() {
parts = append(parts, "--"+f.Name, v)
}
return
}
parts = append(parts, "--"+f.Name, f.Value.String())
})
return append(parts, "-y")
}
// confirmCaveat returns the trailing safety note shown after the interactive
// prompt, tailored to the verb. Deletes/removals are irreversible; edits
// overwrite prior values but are recoverable if you know the old value.
func confirmCaveat(verb string) string {
switch verb {
case "edit":
return "This overwrites the current values."
default: // delete / remove — irreversible
return "This cannot be undone."
}
}
// titleFirst upper-cases the first rune so the interactive prompt reads as a
// sentence ("Delete …?" / "Edit …?") without pulling in golang.org/x/text.
func titleFirst(s string) string {
if s == "" {
return s
}
r, size := utf8.DecodeRuneInString(s)
return string(unicode.ToUpper(r)) + s[size:]
}
// ConfirmDestructiveBatch is the multi-id flavor of ConfirmDestructive: same
// behavior matrix (yes / non-TTY / TTY-prompt / user-no) but the prompt text
// reflects the count, not a single id. Used by `doc delete <id> [<id>...]`
// — one -y confirms all items in the batch.
//
// Pass n = total count of items about to be deleted.
// action is the namespaced action verb (e.g. "doc.delete") for the risk envelope.
// retryArgv is the directly-executable retry argv array
// (e.g. []string{"weknora","doc","delete","a","b","-y"}); pass nil when no
// clean retry argv is available.
func ConfirmDestructiveBatch(p prompt.Prompter, yes, jsonOut bool, verb, what string, n int, action string, retryArgv []string) error {
if yes {
return nil
}
if !iostreams.IO.IsStdoutTTY() || jsonOut {
return NewError(
CodeInputConfirmationRequired,
fmt.Sprintf("%s %d %s(s) requires explicit confirmation: re-run with -y/--yes", verb, n, what),
).
WithRetryArgv(retryArgv).
WithRisk("destructive", action)
}
ok, err := p.Confirm(fmt.Sprintf("%s %d %s(s)? %s", titleFirst(verb), n, what, confirmCaveat(verb)), false)
if err != nil {
return Wrapf(CodeInputMissingFlag, err, "confirm batch delete")
}
if !ok {
fmt.Fprintln(iostreams.IO.Err, "Aborted.")
return NewError(CodeUserAborted, "delete aborted")
}
return nil
}
// ConfirmDestructive guards a destructive operation (delete, force-overwrite)
// behind explicit user approval. Behavior matrix:
//
// yes=true → proceed (explicit user opt-in via -y/--yes)
// non-TTY OR jsonOut → return CodeInputConfirmationRequired (exit 10);
// no UI to prompt, agent/CI must re-invoke with -y
// after the human explicitly approves
// TTY + interactive → prompt; user-yes proceeds, user-no returns
// CodeUserAborted ("Aborted." to stderr)
// prompter error → returns CodeInputMissingFlag (rare; stdin closed
// mid-prompt)
//
// The non-TTY branch is the destructive-write protocol: high-risk writes
// always require explicit confirmation in scripted contexts, never silent
// proceed. See cli/README.md "Exit codes".
//
// `yes` should be sourced from the persistent global -y/--yes flag.
// action is the namespaced action verb (e.g. "kb.delete") for the risk envelope.
// retryArgv is the directly-executable retry argv array
// (e.g. []string{"weknora","kb","delete","kb_x","-y"}); pass nil when no
// clean retry argv is available.
func ConfirmDestructive(p prompt.Prompter, yes, jsonOut bool, verb, what, id, action string, retryArgv []string) error {
return confirmGated(p, yes, jsonOut, verb, what, id, action, RiskDestructive, retryArgv)
}
// ConfirmWrite guards a reversible metadata write (kb / agent / doc update).
// Same confirmation gate as ConfirmDestructive — a labeling-accuracy variant,
// NOT a weaker gate — but tags the risk envelope at the "write" level so an
// agent can distinguish a recoverable edit from an irreversible delete.
func ConfirmWrite(p prompt.Prompter, yes, jsonOut bool, verb, what, id, action string, retryArgv []string) error {
return confirmGated(p, yes, jsonOut, verb, what, id, action, RiskWrite, retryArgv)
}
// confirmGated is the shared single-resource confirmation gate behind
// ConfirmDestructive / ConfirmWrite. `level` is the risk.level reported on the
// exit-10 envelope (RiskDestructive for irreversible ops, RiskWrite for
// recoverable edits). Behavior matrix:
//
// yes=true → proceed (explicit user opt-in via -y/--yes)
// non-TTY OR jsonOut → CodeInputConfirmationRequired (exit 10) + risk{level,action};
// no UI to prompt, agent/CI must re-invoke with -y after
// the human explicitly approves
// TTY + interactive → prompt; user-yes proceeds, user-no returns CodeUserAborted
// prompter error → CodeInputMissingFlag (rare; stdin closed mid-prompt)
func confirmGated(p prompt.Prompter, yes, jsonOut bool, verb, what, id, action, level string, retryArgv []string) error {
if yes {
return nil
}
if !iostreams.IO.IsStdoutTTY() || jsonOut {
return NewError(
CodeInputConfirmationRequired,
fmt.Sprintf("%s %s %s requires explicit confirmation: re-run with -y/--yes", verb, what, id),
).
WithRetryArgv(retryArgv).
WithRisk(level, action)
}
ok, err := p.Confirm(fmt.Sprintf("%s %s %s? %s", titleFirst(verb), what, id, confirmCaveat(verb)), false)
if err != nil {
return Wrapf(CodeInputMissingFlag, err, "confirm %s", verb)
}
if !ok {
fmt.Fprintln(iostreams.IO.Err, "Aborted.")
return NewError(CodeUserAborted, fmt.Sprintf("%s aborted", verb))
}
return nil
}
+116
View File
@@ -0,0 +1,116 @@
package cmdutil_test
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/testutil"
)
// The confirmation message verb must match the actual operation: an `edit`
// must not be described as `delete`. Regression for the hardcoded-"delete"
// confirmation message that mislabeled kb/agent updates.
func TestConfirmDestructive_VerbMatchesOperation(t *testing.T) {
iostreams.SetForTest(t) // non-TTY buffers ⇒ the jsonOut/non-TTY branch
cases := []struct {
verb, what, id string
wantPrefix string
wantNotContains string
}{
{"edit", "knowledge base", "kb_x", "edit knowledge base kb_x requires", "delete"},
{"delete", "knowledge base", "kb_x", "delete knowledge base kb_x requires", ""},
{"remove", "current profile", "prod", "remove current profile prod requires", "delete"},
}
for _, tc := range cases {
err := cmdutil.ConfirmDestructive(&testutil.ConfirmPrompter{}, false, true, tc.verb, tc.what, tc.id, tc.what+"."+tc.verb, nil)
if err == nil {
t.Fatalf("verb %q: expected confirmation_required error", tc.verb)
}
msg := err.Error()
if !strings.Contains(msg, tc.wantPrefix) {
t.Errorf("verb %q: message %q does not contain %q", tc.verb, msg, tc.wantPrefix)
}
if tc.wantNotContains != "" && strings.Contains(msg, tc.wantNotContains) {
t.Errorf("verb %q: message %q must not contain %q", tc.verb, msg, tc.wantNotContains)
}
if typed := cmdutil.AsError(err); typed == nil || typed.Code != cmdutil.CodeInputConfirmationRequired {
t.Errorf("verb %q: expected CodeInputConfirmationRequired, got %v", tc.verb, err)
}
}
}
// The batch flavor must likewise honor the verb.
func TestConfirmDestructiveBatch_VerbMatchesOperation(t *testing.T) {
iostreams.SetForTest(t)
err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, true, "delete", "document", 3, "doc.delete", nil)
if err == nil {
t.Fatal("expected confirmation_required error")
}
if !strings.Contains(err.Error(), "delete 3 document(s) requires") {
t.Errorf("unexpected batch message: %q", err.Error())
}
}
func TestBuildRetryArgv_ScalarsAndSlices(t *testing.T) {
cmd := &cobra.Command{Use: "update"}
var name string
var addKBs []string
var format string
cmd.Flags().StringVar(&name, "name", "", "")
cmd.Flags().StringSliceVar(&addKBs, "add-kb", nil, "")
cmd.Flags().StringVar(&format, "format", "", "")
if err := cmd.Flags().Parse([]string{
"--add-kb", "kb_new",
"--add-kb", "kb_old",
"--name", "Renamed",
"--format", "json",
}); err != nil {
t.Fatal(err)
}
got := cmdutil.BuildRetryArgv(cmd, []string{"weknora", "agent", "update", "ag_abc"},
"name", "add-kb", "format")
// Visit order is lexicographical among changed flags.
want := []string{
"weknora", "agent", "update", "ag_abc",
"--add-kb", "kb_new",
"--add-kb", "kb_old",
"--format", "json",
"--name", "Renamed",
"-y",
}
if len(got) != len(want) {
t.Fatalf("len got=%d want=%d\ngot=%v\nwant=%v", len(got), len(want), got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("idx %d: got %q want %q\nfull got=%v", i, got[i], want[i], got)
}
}
}
func TestBuildRetryArgv_SkipsFlagsNotInAllow(t *testing.T) {
cmd := &cobra.Command{Use: "update"}
var name, secret string
cmd.Flags().StringVar(&name, "name", "", "")
cmd.Flags().StringVar(&secret, "api-key-stdin", "", "")
if err := cmd.Flags().Parse([]string{"--name", "x", "--api-key-stdin", "-"}); err != nil {
t.Fatal(err)
}
got := cmdutil.BuildRetryArgv(cmd, []string{"weknora", "model", "update", "m1"}, "name", "format")
want := []string{"weknora", "model", "update", "m1", "--name", "x", "-y"}
if len(got) != len(want) {
t.Fatalf("len got=%d want=%d\ngot=%v\nwant=%v", len(got), len(want), got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("idx %d: got %q want %q\nfull got=%v", i, got[i], want[i], got)
}
}
}
+128
View File
@@ -0,0 +1,128 @@
// Package-level note:
//
// AddDryRunFlag + EmitDryRun + DryRunPlan + HandleDryRun support the
// --dry-run contract for mutation commands:
// - Each mutation cobra command registers --dry-run via AddDryRunFlag.
// - RunE early-exits (BEFORE ConfirmDestructive / Factory.Client() /
// ResolveKB) when opts.DryRun is true, calling EmitDryRun with a
// DryRunPlan describing the would-be action.
// - The api command additionally rejects --dry-run on GET (FlagError).
//
// Side-effect suppression: the dry-run path must NOT call the SDK, write
// the keyring, write .weknora/project.yaml, or write download files.
package cmdutil
import (
"encoding/json"
"fmt"
"io"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
)
// DryRunPlan describes the would-be action for envelope meta.plan.
// Field tags use omitempty for api-extra fields so mutation commands
// emit only {action, args} and api commands emit {action, method, path, body}.
type DryRunPlan struct {
Action string `json:"action"`
Args map[string]any `json:"args,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Body any `json:"body,omitempty"`
}
// AddDryRunFlag registers --dry-run on a cobra command, binding to a bool
// pointer. A bool is sufficient because the CLI is single-tenant and
// self-hosted — there is no client/server distinction to encode.
func AddDryRunFlag(cmd *cobra.Command, dest *bool) {
cmd.Flags().BoolVar(dest, "dry-run", false,
"Preview the action without executing (offline; no SDK calls, no file IO)")
}
// EmitDryRun writes the dry-run envelope to w via FormatOptions.Emit, so the
// envelope inherits the standard TTY indent, --jq filter, and profile fields
// like every other success envelope. fopts may be nil for
// callers that have no resolved FormatOptions (tests, error paths); a JSON-mode
// default is used in that case.
//
// The envelope shape is {ok:true, meta:{dry_run:true, plan:{...}}, ...}; data
// is intentionally omitted (omitempty on Envelope.Data) since dry-run produced
// no real data.
//
// Caller must ensure NO side effects occurred before this point: no SDK call,
// no file write, no keyring touch.
func EmitDryRun(w io.Writer, fopts *FormatOptions, plan DryRunPlan) error {
planMap, err := planToMap(plan)
if err != nil {
return fmt.Errorf("dry-run: encode plan: %w", err)
}
meta := &output.Meta{
DryRun: true,
Plan: planMap,
}
if fopts == nil {
fopts = &FormatOptions{Mode: FormatJSON}
}
// NDJSON mode is meaningless for a single dry-run envelope (the contract
// emits exactly one object). Fall back to JSON envelope shape so meta.plan
// is surfaced; this matches what every other mutation emit does.
if fopts.Mode == FormatNDJSON {
jsonOpts := *fopts
jsonOpts.Mode = FormatJSON
return jsonOpts.Emit(w, nil, meta)
}
return fopts.Emit(w, nil, meta)
}
// HandleDryRun bundles the dry-run preamble for mutation command RunE:
// FormatOptions resolution + envelope emit. Returns (handled, err) — when
// handled is true, RunE should return err immediately without doing any
// SDK / Factory.Client() / ResolveKB / ConfirmDestructive work.
//
// Pattern:
//
// if handled, err := cmdutil.HandleDryRun(cmd, opts.DryRun, cmdutil.DryRunPlan{
// Action: "kb.create",
// Args: map[string]any{"name": opts.Name},
// }); handled {
// return err
// }
//
// The helper invokes ONLY local config reads (CheckFormatFlag,
// IsStdoutTTY) — no SDK calls, no keyring writes, no file IO. Safe to
// call before any other RunE logic.
//
// The helper re-resolves FormatOptions internally so callsites stay
// single-statement; this is intentionally redundant with the outer
// CheckFormatFlag the non-dry-run success path still needs. The duplication
// is cheap (local flag read) and is the price of consolidating the dry-run
// preamble into one helper.
func HandleDryRun(cmd *cobra.Command, dryRun bool, plan DryRunPlan) (handled bool, err error) {
if !dryRun {
return false, nil
}
fopts, err := CheckFormatFlag(cmd)
if err != nil {
return true, err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
return true, EmitDryRun(iostreams.IO.Out, fopts, plan)
}
// planToMap converts a DryRunPlan to map[string]any so it can populate
// output.Meta.Plan (which is open-typed). Goes through json to honor the
// omitempty tags so api-only fields don't leak into mutation envelopes.
func planToMap(plan DryRunPlan) (map[string]any, error) {
b, err := json.Marshal(plan)
if err != nil {
return nil, err
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
return m, nil
}
+202
View File
@@ -0,0 +1,202 @@
package cmdutil
import (
"bytes"
"encoding/json"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
func TestAddDryRunFlag_RegistersFlag(t *testing.T) {
cmd := &cobra.Command{Use: "create"}
var dryRun bool
AddDryRunFlag(cmd, &dryRun)
flag := cmd.Flags().Lookup("dry-run")
require.NotNil(t, flag, "expected --dry-run flag registered")
assert.Equal(t, "false", flag.DefValue, "default should be false")
}
func TestEmitDryRun_EmitsEnvelopeWithPlan(t *testing.T) {
var buf bytes.Buffer
plan := DryRunPlan{
Action: "kb.create",
Args: map[string]any{
"name": "foo",
"description": "bar",
},
}
err := EmitDryRun(&buf, &FormatOptions{Mode: FormatJSON}, plan)
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, true, got["ok"])
assert.NotContains(t, got, "data", "data should be omitempty when nil")
meta, ok := got["meta"].(map[string]any)
require.True(t, ok, "expected meta object")
assert.Equal(t, true, meta["dry_run"])
planOut, ok := meta["plan"].(map[string]any)
require.True(t, ok, "expected meta.plan object")
assert.Equal(t, "kb.create", planOut["action"])
args, ok := planOut["args"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "foo", args["name"])
assert.Equal(t, "bar", args["description"])
}
func TestEmitDryRun_ApiPlanShape(t *testing.T) {
var buf bytes.Buffer
plan := DryRunPlan{
Action: "api.post",
Method: "POST",
Path: "/api/v1/knowledge-bases",
Body: map[string]any{"name": "foo"},
}
err := EmitDryRun(&buf, &FormatOptions{Mode: FormatJSON}, plan)
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
planOut := got["meta"].(map[string]any)["plan"].(map[string]any)
assert.Equal(t, "api.post", planOut["action"])
assert.Equal(t, "POST", planOut["method"])
assert.Equal(t, "/api/v1/knowledge-bases", planOut["path"])
body, ok := planOut["body"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "foo", body["name"])
}
func TestEmitDryRun_HonorsTTYIndent(t *testing.T) {
var buf bytes.Buffer
err := EmitDryRun(&buf, &FormatOptions{Mode: FormatJSON, TTY: true},
DryRunPlan{Action: "kb.create"})
require.NoError(t, err)
got := buf.String()
assert.Contains(t, got, "\n \"", "TTY=true should produce indented JSON; got %q", got)
}
func TestEmitDryRun_HonorsJQ(t *testing.T) {
// jq runs against the full envelope, so the user can project any
// envelope field (.meta.dry_run, .meta.plan.action, etc.). Routing
// through FormatOptions.Emit is what makes this work.
var buf bytes.Buffer
err := EmitDryRun(&buf, &FormatOptions{Mode: FormatJSON, JQ: ".meta.plan"},
DryRunPlan{Action: "kb.create", Args: map[string]any{"name": "foo"}})
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, "kb.create", got["action"])
args, ok := got["args"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "foo", args["name"])
// Sanity: jq output should NOT carry the surrounding envelope fields.
assert.NotContains(t, got, "ok")
assert.NotContains(t, got, "meta")
}
func TestEmitDryRun_NilFormatOptionsDefaultsJSON(t *testing.T) {
var buf bytes.Buffer
err := EmitDryRun(&buf, nil, DryRunPlan{Action: "kb.create"})
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, true, got["ok"])
meta, ok := got["meta"].(map[string]any)
require.True(t, ok)
assert.Equal(t, true, meta["dry_run"])
}
func TestEmitDryRun_NDJSONFallsBackToJSON(t *testing.T) {
// NDJSON mode is meaningless for a single dry-run envelope; the helper
// should fall back to JSON envelope shape so meta.plan is surfaced.
var buf bytes.Buffer
err := EmitDryRun(&buf, &FormatOptions{Mode: FormatNDJSON},
DryRunPlan{Action: "kb.create"})
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, true, got["ok"])
meta, ok := got["meta"].(map[string]any)
require.True(t, ok)
assert.Equal(t, true, meta["dry_run"])
}
func TestHandleDryRun_NotDryRun_SkipsAndReturnsHandledFalse(t *testing.T) {
// dryRun=false short-circuits before any flag read / IO touch; helper
// returns (false, nil) so RunE proceeds to the live SDK path.
cmd := &cobra.Command{Use: "x"}
var dryRun bool
AddDryRunFlag(cmd, &dryRun)
handled, err := HandleDryRun(cmd, false, DryRunPlan{Action: "kb.create"})
assert.False(t, handled, "handled must be false when dryRun=false")
assert.NoError(t, err)
}
func TestHandleDryRun_DryRun_EmitsEnvelopeAndReturnsHandledTrue(t *testing.T) {
// dryRun=true → helper resolves FormatOptions, emits the envelope to
// iostreams.IO.Out, and returns (true, nil). RunE returns the error
// immediately without touching SDK.
out, _ := iostreams.SetForTest(t)
cmd := &cobra.Command{Use: "x"}
var dryRun bool
AddDryRunFlag(cmd, &dryRun)
// --format is a persistent root flag in production; for the unit test
// we register it locally so CheckFormatFlag has something to read.
cmd.Flags().String("format", "", "")
cmd.Flags().String("jq", "", "")
require.NoError(t, cmd.Flags().Set("format", "json"))
handled, err := HandleDryRun(cmd, true, DryRunPlan{
Action: "kb.create",
Args: map[string]any{"name": "foo"},
})
assert.True(t, handled, "handled must be true when dryRun=true")
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(out.Bytes(), &got))
assert.Equal(t, true, got["ok"])
meta, ok := got["meta"].(map[string]any)
require.True(t, ok)
assert.Equal(t, true, meta["dry_run"])
plan, ok := meta["plan"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "kb.create", plan["action"])
}
func TestHandleDryRun_InvalidFormat_ReturnsHandledTrueWithError(t *testing.T) {
// Invalid --format must propagate as (true, FlagError) so the caller
// returns immediately; otherwise a broken --format would silently fall
// through to the live SDK path.
_, _ = iostreams.SetForTest(t)
cmd := &cobra.Command{Use: "x"}
var dryRun bool
AddDryRunFlag(cmd, &dryRun)
cmd.Flags().String("format", "", "")
require.NoError(t, cmd.Flags().Set("format", "garbage"))
handled, err := HandleDryRun(cmd, true, DryRunPlan{Action: "kb.create"})
assert.True(t, handled, "handled must be true even on flag error so RunE stops")
require.Error(t, err)
}
func TestEmitDryRun_PopulatesProfile(t *testing.T) {
SetProfile("staging")
t.Cleanup(func() { SetProfile("") })
var buf bytes.Buffer
err := EmitDryRun(&buf, &FormatOptions{Mode: FormatJSON}, DryRunPlan{Action: "kb.create"})
require.NoError(t, err)
assert.Contains(t, buf.String(), `"profile":"staging"`, "expected profile field; got %q", buf.String())
}
+489
View File
@@ -0,0 +1,489 @@
// Package cmdutil contains the Factory, Options helpers, error types,
// JSON-flag wiring, and the Exporter abstraction shared by all commands.
package cmdutil
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/Tencent/WeKnora/cli/internal/output"
sdk "github.com/Tencent/WeKnora/client"
)
// ErrorCode is a namespaced stable identifier emitted on stderr in the
// `code: message` failure line. SemVer governance: v0.x maintains the
// registry below; new codes are noted in release notes.
type ErrorCode string
const (
// auth.* - authentication / permission
CodeAuthUnauthenticated ErrorCode = "auth.unauthenticated"
CodeAuthTokenExpired ErrorCode = "auth.token_expired"
CodeAuthBadCredential ErrorCode = "auth.bad_credential"
CodeAuthForbidden ErrorCode = "auth.forbidden"
CodeAuthCrossTenantBlocked ErrorCode = "auth.cross_tenant_blocked"
CodeAuthTenantMismatch ErrorCode = "auth.tenant_mismatch"
// resource.*
CodeResourceNotFound ErrorCode = "resource.not_found"
CodeResourceAlreadyExists ErrorCode = "resource.already_exists"
CodeResourceLocked ErrorCode = "resource.locked"
// input.* - flag and argument validation
CodeInputInvalidArgument ErrorCode = "input.invalid_argument"
CodeInputMissingFlag ErrorCode = "input.missing_flag"
// CodeInputConfirmationRequired marks a high-risk write that has no
// interactive UI (non-TTY or JSON-output mode) and was invoked without
// -y/--yes. Mapped to exit code 10 (see cli/README.md). Agents must
// 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"
CodeServerTimeout ErrorCode = "server.timeout"
CodeServerRateLimited ErrorCode = "server.rate_limited"
CodeServerIncompatibleVersion ErrorCode = "server.incompatible_version"
CodeNetworkError ErrorCode = "network.error"
// CodeSessionCreateFailed marks a chat invocation where the auto-created
// session POST failed. Surfaced as a typed code distinct from generic
// server.error so agents can retry with their own --session.
CodeSessionCreateFailed ErrorCode = "server.session_create_failed"
// operation.* - CLI-level wait/poll results
// CodeOperationTimeout marks a CLI-level wait/poll operation that exhausted
// its --timeout window. Distinct from CodeServerTimeout (HTTP 504). Mapped
// to exit 124 (matches the convention from GNU `timeout`).
CodeOperationTimeout ErrorCode = "operation.timeout"
// CodeOperationFailed marks a CLI-level wait/poll operation where one or
// more targets reached a terminal failure (e.g. doc wait found a doc with
// parse_status=failed). Distinct from server.* / network.* because the
// failure is the target's own terminal state, not a transient transport
// issue. Maps to exit 1 via the fall-through bucket.
CodeOperationFailed ErrorCode = "operation.failed"
// CodeOperationCancelled marks a long-running command interrupted by a
// caught signal (SIGINT / SIGTERM after main.go's signal.NotifyContext
// fires). Distinct from CodeUserAborted (declined confirm prompt) — the
// hints differ. main.go overrides the exit code to 130 for cancelled
// contexts so the user-visible exit follows Unix signal convention.
CodeOperationCancelled ErrorCode = "operation.cancelled"
// CodeInternalError is the catch-all for an error that reached the top
// without a typed code (a bug, or an unmapped dependency error). Maps to
// exit 1 via the fall-through bucket. Documented so the type is never a
// surprise on the wire; a recurring internal.error is a classification gap
// worth fixing at the source.
CodeInternalError ErrorCode = "internal.error"
// local.* - config / file / keychain on the user's machine
CodeLocalConfigCorrupt ErrorCode = "local.config_corrupt"
CodeLocalKeychainDenied ErrorCode = "local.keychain_denied"
CodeLocalFileIO ErrorCode = "local.file_io"
CodeLocalUnimplemented ErrorCode = "local.unimplemented"
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"
CodeProjectLinkCorrupt ErrorCode = "local.project_link_corrupt"
// CodeUserAborted marks a user-cancelled destructive operation (declined a
// confirm prompt). Distinct from SilentError so the stderr line still
// carries a stable code; distinct from input.* because the user supplied
// valid args and simply chose not to proceed.
CodeUserAborted ErrorCode = "local.user_aborted"
// CodeUploadFileNotFound marks a `weknora doc upload` invocation pointing at
// a path that does not exist. Distinct from CodeLocalFileIO (permission /
// disk-fault) so the hint can name the actual culprit.
CodeUploadFileNotFound ErrorCode = "local.upload_file_not_found"
// CodeSSEStreamAborted marks a streaming RAG response that began producing
// data and then dropped before the SDK observed a Done event. Distinct
// from network.error (pre-stream transport failure) so users see the
// stream specifically aborted, not a connection that never opened.
CodeSSEStreamAborted ErrorCode = "local.sse_stream_aborted"
)
// Error is the typed error implementations carry through the call stack.
// RunE returns a *Error and the root command renders it on stderr in
// `code: message[: cause]\nhint: ...` form. Exit code is derived by
// ExitCode().
type Error struct {
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
RetryArgv []string // Directly-executable argv array, 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 {
if e == nil {
return ""
}
if e.Cause != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
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
}
// WithRetryArgv sets the directly-executable retry argv array so agents can
// exec it directly instead of regex-extracting argv from the prose Hint.
// Empty for codes without a canonical retry command.
func (e *Error) WithRetryArgv(argv []string) *Error {
e.RetryArgv = argv
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
}
// WithSilent suppresses PrintError's stderr envelope while preserving the Code
// for ExitCode. Use when the command already emitted a structured outcome
// envelope to stdout (e.g. batch / wait partitions) and a second error envelope
// on stderr would contradict it. Mirrors RunBatch's Silent behavior.
func (e *Error) WithSilent() *Error {
e.Silent = true
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 / RetryArgv fall back
// to defaultHint / defaultRetryArgv 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.RetryArgv
if len(retry) == 0 {
retry = defaultRetryArgv(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,
ExitCode: ExitCode(err),
Hint: hint,
RetryArgv: retry,
RetryAfterSeconds: typed.RetryAfterSeconds,
Retryable: retryableForCode(typed.Code),
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(),
ExitCode: ExitCode(err), // FlagError → 2, distinguishing parse from typed-value (exit 5)
Hint: defaultHint(CodeInputInvalidArgument),
Retryable: retryableForCode(CodeInputInvalidArgument),
}
}
return &output.ErrDetail{Type: string(CodeInternalError), Message: err.Error(), ExitCode: ExitCode(err)}
}
// NewError constructs a typed error.
func NewError(code ErrorCode, message string) *Error {
return &Error{Code: code, Message: message}
}
// Wrapf wraps cause with a typed code and Sprintf-style message.
func Wrapf(code ErrorCode, cause error, format string, args ...any) *Error {
return &Error{Code: code, Message: fmt.Sprintf(format, args...), Cause: cause}
}
// WrapHTTP wraps a transport / response error with the typed code derived
// from its HTTP shape (404 → resource.not_found, 401 → auth.unauthenticated,
// non-HTTP → network.error, …). Shortcut for the universal pattern
// `Wrapf(ClassifyHTTPError(err), err, format, args...)` used by every SDK
// call site - single source for the wrap-and-classify policy.
//
// Use this for any error returned from a wire call. Stays paired with
// ClassifyHTTPErrorOutputs() in the acceptance/contract test, which
// enumerates the codes this helper can yield.
func WrapHTTP(cause error, format string, args ...any) *Error {
return Wrapf(ClassifyHTTPError(cause), cause, format, args...)
}
// ClassifySDKError maps SDK transport, HTTP, and terminal SSE stream errors to
// the canonical ErrorCode. SSE terminal frames classify as server.error rather
// than network.error.
func ClassifySDKError(err error) ErrorCode {
if sdk.IsSSEStreamError(err) {
return CodeServerError
}
return ClassifyHTTPError(err)
}
// WrapStream wraps an SDK streaming error with ClassifySDKError.
func WrapStream(cause error, format string, args ...any) *Error {
return Wrapf(ClassifySDKError(cause), cause, format, args...)
}
// FlagError signals user-visible flag/argument problems; the root command
// prints help on top of the message and exits 2.
type FlagError struct{ err error }
func (e *FlagError) Error() string { return e.err.Error() }
func (e *FlagError) Unwrap() error { return e.err }
// NewFlagError wraps err as a FlagError.
func NewFlagError(err error) error { return &FlagError{err: err} }
// SilentError skips printing to stderr; useful when a command has already
// emitted a fully-formatted message and exits non-zero.
var SilentError = errors.New("silent error (handled)")
// CancelError marks a user-cancelled operation (Ctrl-C / "no" at confirm).
var CancelError = errors.New("operation cancelled")
// Typed predicates - use these instead of comparing ErrorCode strings.
// They walk the error chain so wrapped errors still match.
// IsAuthError matches any auth.* code.
func IsAuthError(err error) bool { return matchPrefix(err, "auth.") }
// IsNotFound matches resource.not_found.
func IsNotFound(err error) bool { return matchCode(err, CodeResourceNotFound) }
// IsTransient matches network.* and server.timeout / rate_limited (worth retrying).
func IsTransient(err error) bool {
return matchPrefix(err, "network.") ||
matchCode(err, CodeServerTimeout) ||
matchCode(err, CodeServerRateLimited)
}
// IsAuthExpired matches auth.token_expired.
func IsAuthExpired(err error) bool { return matchCode(err, CodeAuthTokenExpired) }
// matchCode returns true if err (or anything it wraps) is a *Error with code == c.
// errors.As walks the wrap chain itself; the explicit unwrap loop is unnecessary.
func matchCode(err error, c ErrorCode) bool {
var e *Error
if !errors.As(err, &e) {
return false
}
return e.Code == c
}
// matchPrefix returns true if err (or anything it wraps) is a *Error whose code
// has the given namespace prefix (e.g. "auth.").
func matchPrefix(err error, prefix string) bool {
var e *Error
if !errors.As(err, &e) {
return false
}
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
// (e.g. raw passthrough reading resp.StatusCode).
func ClassifyHTTPStatus(status int) ErrorCode {
switch {
case status == 401:
return CodeAuthUnauthenticated
case status == 403:
return CodeAuthForbidden
case status == 404:
return CodeResourceNotFound
case status == 409:
return CodeResourceAlreadyExists
case status == 429:
return CodeServerRateLimited
case status >= 500:
return CodeServerError
case status >= 400:
return CodeInputInvalidArgument
}
return CodeServerError
}
// ClassifyHTTPError maps an SDK HTTP error to the canonical ErrorCode by
// parsing the "HTTP error <status>: ..." message format the SDK currently
// emits (client.parseResponse). Until the SDK exposes a typed APIError this
// is the lowest-friction way to surface 401/404/429/etc. as the right
// typed code instead of every server-side problem collapsing to
// server.error.
//
// Returns CodeNetworkError when err is not an HTTP error (transport / DNS),
// and CodeServerError when the status can't be parsed.
func ClassifyHTTPError(err error) ErrorCode {
if err == nil {
return ""
}
msg := err.Error()
rest, ok := strings.CutPrefix(msg, "HTTP error ")
if !ok {
return CodeNetworkError
}
end := strings.IndexByte(rest, ':')
if end <= 0 {
return CodeServerError
}
status, perr := strconv.Atoi(rest[:end])
if perr != nil {
return CodeServerError
}
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.
// Used by acceptance/contract/errorcodes_test.go to validate that every code
// referenced in cli/cmd/ is present here. Update this list whenever a new
// ErrorCode constant is added above.
func AllCodes() []ErrorCode {
return []ErrorCode{
// auth
CodeAuthUnauthenticated, CodeAuthTokenExpired, CodeAuthBadCredential,
CodeAuthForbidden, CodeAuthCrossTenantBlocked, CodeAuthTenantMismatch,
// resource
CodeResourceNotFound, CodeResourceAlreadyExists, CodeResourceLocked,
// input
CodeInputInvalidArgument, CodeInputMissingFlag, CodeInputConfirmationRequired,
CodeInputUnknownSubcommand,
// server / network
CodeServerError, CodeServerTimeout, CodeServerRateLimited,
CodeServerIncompatibleVersion, CodeNetworkError,
// local
CodeLocalConfigCorrupt, CodeLocalKeychainDenied, CodeLocalFileIO,
CodeLocalUnimplemented, CodeLocalProfileNotFound,
CodeKBIDRequired, CodeKBNotFound,
CodeProjectLinkCorrupt,
CodeUserAborted, CodeUploadFileNotFound,
CodeSSEStreamAborted, CodeSessionCreateFailed,
// operation
CodeOperationTimeout, CodeOperationFailed, CodeOperationCancelled,
// internal catch-all
CodeInternalError,
}
}
// ClassifyHTTPErrorOutputs returns every code that ClassifyHTTPError can return.
// Bridges the AST-friendly literal model with the dynamic switch inside
// ClassifyHTTPError. errorcodes_test.go uses this to seed the "referenced codes"
// set without trying to AST-introspect a function-call expression.
//
// IMPORTANT: keep in sync with the switch in ClassifyHTTPError.
func ClassifyHTTPErrorOutputs() []ErrorCode {
return []ErrorCode{
CodeAuthUnauthenticated, // 401
CodeAuthForbidden, // 403
CodeResourceNotFound, // 404
CodeResourceAlreadyExists, // 409
CodeServerRateLimited, // 429
CodeServerError, // 5xx / parse-failure / default
CodeInputInvalidArgument, // 4xx (else)
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
}
+57
View File
@@ -0,0 +1,57 @@
package cmdutil_test
import (
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
func TestAllCodes_NonEmpty(t *testing.T) {
codes := cmdutil.AllCodes()
if len(codes) == 0 {
t.Fatal("AllCodes() should return registered codes")
}
// Sentinel: contains the baseline error codes the registry must always carry.
want := map[cmdutil.ErrorCode]bool{
cmdutil.CodeAuthUnauthenticated: false,
cmdutil.CodeResourceNotFound: false,
cmdutil.CodeNetworkError: false,
}
for _, c := range codes {
if _, ok := want[c]; ok {
want[c] = true
}
}
for c, ok := range want {
if !ok {
t.Errorf("AllCodes() missing %q", c)
}
}
}
func TestAllCodes_NoDuplicates(t *testing.T) {
codes := cmdutil.AllCodes()
seen := make(map[cmdutil.ErrorCode]struct{})
for _, c := range codes {
if _, dup := seen[c]; dup {
t.Errorf("AllCodes() duplicate: %q", c)
}
seen[c] = struct{}{}
}
}
func TestClassifyHTTPErrorOutputs_Subset(t *testing.T) {
outs := cmdutil.ClassifyHTTPErrorOutputs()
all := make(map[cmdutil.ErrorCode]struct{})
for _, c := range cmdutil.AllCodes() {
all[c] = struct{}{}
}
for _, c := range outs {
if _, ok := all[c]; !ok {
t.Errorf("ClassifyHTTPErrorOutputs returns %q which is not in AllCodes", c)
}
}
if len(outs) < 5 {
t.Errorf("expected at least 5 codes, got %d", len(outs))
}
}
+116
View File
@@ -0,0 +1,116 @@
package cmdutil
import (
"os"
"path/filepath"
"strings"
"testing"
)
// loadErrorReferenceSection returns the body of the cli/AGENTS.md "Error code
// reference" section, delimited by the ERROR_REFERENCE_START/END markers.
func loadErrorReferenceSection(t *testing.T) string {
t.Helper()
// From cli/internal/cmdutil/, go up two levels to find cli/AGENTS.md.
docPath, err := filepath.Abs("../../AGENTS.md")
if err != nil {
t.Fatalf("abs: %v", err)
}
content, err := os.ReadFile(docPath)
if err != nil {
t.Fatalf("read %s: %v", docPath, err)
}
doc := string(content)
const startMarker = "<!-- ERROR_REFERENCE_START -->"
const endMarker = "<!-- ERROR_REFERENCE_END -->"
startIdx := strings.Index(doc, startMarker)
endIdx := strings.Index(doc, endMarker)
if startIdx == -1 || endIdx == -1 || endIdx <= startIdx {
t.Fatalf("error-reference markers missing or malformed in %s:\n start=%d end=%d", docPath, startIdx, endIdx)
}
return doc[startIdx:endIdx]
}
// documentedCodes extracts the codes listed in the first column of the error
// reference table. Only the first column is read: hint cells also carry
// backticked flags, paths, and commands that are not error codes.
func documentedCodes(refSection string) []string {
var codes []string
for _, line := range strings.Split(refSection, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "|") {
continue
}
cells := strings.Split(strings.Trim(line, "|"), "|")
if len(cells) == 0 {
continue
}
// Skips the header ("Code") and separator ("---") rows, which carry no
// backticks in the first column.
first := strings.TrimSpace(cells[0])
if len(first) < 3 || !strings.HasPrefix(first, "`") || !strings.HasSuffix(first, "`") {
continue
}
codes = append(codes, strings.Trim(first, "`"))
}
return codes
}
// TestAllCodes_DocumentedInAGENTS verifies every typed code returned by
// AllCodes() surfaces in cli/AGENTS.md "Error code reference" section
// (delimited by ERROR_REFERENCE_START/END markers).
//
// Prevents drift: a contributor adding a new ErrorCode without updating
// the doc fails this test, forcing the doc to stay current.
func TestAllCodes_DocumentedInAGENTS(t *testing.T) {
refSection := loadErrorReferenceSection(t)
missing := []string{}
for _, c := range AllCodes() {
needle := "`" + string(c) + "`"
if !strings.Contains(refSection, needle) {
missing = append(missing, string(c))
}
}
if len(missing) > 0 {
t.Errorf("the following error codes are registered in AllCodes() but not listed in cli/AGENTS.md \"Error code reference\" section between the ERROR_REFERENCE markers:\n - %s\n\nAdd a row for each missing code to keep agent-facing docs in sync.",
strings.Join(missing, "\n - "))
}
}
// TestDocumentedCodes_RegisteredInAllCodes is the reverse of
// TestAllCodes_DocumentedInAGENTS: every code the doc advertises must still be
// a code the CLI can emit.
//
// Without this direction a removed code lingers in the doc forever, and agents
// branch on codes that will never arrive — the failure mode that left three
// `mcp.*` codes documented for two minor versions after they were deleted.
func TestDocumentedCodes_RegisteredInAllCodes(t *testing.T) {
refSection := loadErrorReferenceSection(t)
registered := make(map[string]bool, len(AllCodes()))
for _, c := range AllCodes() {
registered[string(c)] = true
}
documented := documentedCodes(refSection)
// Guards against the parser silently matching nothing, which would make this
// check pass vacuously if the table format ever changes.
if len(documented) == 0 {
t.Fatal("parsed zero error codes out of the reference table; the table format or the parser changed")
}
stale := []string{}
for _, c := range documented {
if !registered[c] {
stale = append(stale, c)
}
}
if len(stale) > 0 {
t.Errorf("the following error codes are documented in cli/AGENTS.md "+
"\"Error code reference\" but are not registered in AllCodes():\n - %s\n\n"+
"Drop the row if the code was removed, or register the code if the row is correct.",
strings.Join(stale, "\n - "))
}
}
+62
View File
@@ -0,0 +1,62 @@
package cmdutil
import (
"errors"
"reflect"
"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_WithRetryArgv(t *testing.T) {
err := NewError(CodeAuthUnauthenticated, "session expired").
WithHint("run `weknora auth login`").
WithRetryArgv([]string{"weknora", "auth", "login"})
if !reflect.DeepEqual(err.RetryArgv, []string{"weknora", "auth", "login"}) {
t.Errorf("RetryArgv not set; got %v", err.RetryArgv)
}
if err.Hint != "run `weknora auth login`" {
t.Errorf("Hint changed unexpectedly; got %q", err.Hint)
}
}
func TestError_RetryArgv_EmptyByDefault(t *testing.T) {
err := NewError(CodeResourceAlreadyExists, "kb name exists")
if len(err.RetryArgv) != 0 {
t.Errorf("RetryArgv should default empty; got %v", err.RetryArgv)
}
}
// TestErrorToDetail_CarriesExitCode verifies every error detail embeds the
// authoritative exit_code so an agent can branch on a single JSON read without
// observing $?. Regression: `input.invalid_argument` spans exit 2 (parse) and
// exit 5 (typed value), so `type` alone was insufficient — exit_code
// disambiguates them in the envelope.
func TestErrorToDetail_CarriesExitCode(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"not_found", NewError(CodeResourceNotFound, "x"), 4},
{"typed_input_value", NewError(CodeInputInvalidArgument, "bad value"), 5},
{"auth", NewError(CodeAuthUnauthenticated, "x"), 3},
{"parse_flagerror", NewFlagError(errors.New("unknown flag: --nope")), 2},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d := ErrorToDetail(tc.err)
if d == nil {
t.Fatal("nil detail")
}
if d.ExitCode != tc.want {
t.Errorf("exit_code = %d, want %d (type=%s)", d.ExitCode, tc.want, d.Type)
}
})
}
}
+112
View File
@@ -0,0 +1,112 @@
package cmdutil
import (
"errors"
"fmt"
"testing"
sdk "github.com/Tencent/WeKnora/client"
"github.com/stretchr/testify/assert"
)
func TestClassifyHTTPError(t *testing.T) {
cases := []struct {
name string
err error
want ErrorCode
}{
{"nil", nil, ""},
{"non-HTTP transport", errors.New("dial tcp: lookup host: no such host"), CodeNetworkError},
{"401", fmt.Errorf("HTTP error 401: invalid token"), CodeAuthUnauthenticated},
{"403", fmt.Errorf("HTTP error 403: forbidden"), CodeAuthForbidden},
{"404", fmt.Errorf("HTTP error 404: kb not found"), CodeResourceNotFound},
{"409", fmt.Errorf("HTTP error 409: already exists"), CodeResourceAlreadyExists},
{"429", fmt.Errorf("HTTP error 429: slow down"), CodeServerRateLimited},
{"500", fmt.Errorf("HTTP error 500: internal"), CodeServerError},
{"503", fmt.Errorf("HTTP error 503: unavailable"), CodeServerError},
{"400 generic", fmt.Errorf("HTTP error 400: bad input"), CodeInputInvalidArgument},
{"422 unprocessable", fmt.Errorf("HTTP error 422: validation"), CodeInputInvalidArgument},
{"malformed status", fmt.Errorf("HTTP error abc: oops"), CodeServerError},
{"missing colon", fmt.Errorf("HTTP error 404 no colon"), CodeServerError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, ClassifyHTTPError(tc.err))
})
}
}
func TestClassifySDKError_SSEStreamTerminal(t *testing.T) {
err := sdk.NewSSEStreamError("boom")
assert.Equal(t, CodeServerError, ClassifySDKError(err))
assert.Equal(t, CodeNetworkError, ClassifyHTTPError(err))
}
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)
}
})
}
}
+256
View File
@@ -0,0 +1,256 @@
package cmdutil
import (
"errors"
"fmt"
"io"
"strings"
"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.*, operation.failed, server.session_create_failed
// (workflow-level, see special case below), and any code outside the named
// buckets below
// - 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)
// - 6 server.rate_limited
// - 7 server.* (other than rate_limited/session_create_failed) / network.*
// - 10 input.confirmation_required - high-risk write needs explicit -y
// (see cli/README.md)
// - 124 operation.timeout - CLI-level wait/poll exhausted its --timeout window
// (matches the convention from GNU `timeout`)
// - 130 SIGINT (handled by Go runtime, not this function)
func ExitCode(err error) int {
if err == nil {
return 0
}
var fe *FlagError
if errors.As(err, &fe) {
return 2
}
if errors.Is(err, SilentError) {
return 1
}
if matchCode(err, CodeInputConfirmationRequired) {
return 10
}
if IsAuthError(err) {
return 3
}
if IsNotFound(err) {
return 4
}
if matchPrefix(err, "input.") {
return 5
}
if matchCode(err, CodeServerRateLimited) {
return 6
}
// server.session_create_failed is a workflow-level failure (the hint
// asks the caller to pass --session, not to retry with backoff), so it
// falls through to exit 1 rather than the server.* transient bucket.
if matchCode(err, CodeSessionCreateFailed) {
return 1
}
if matchPrefix(err, "server.") || matchPrefix(err, "network.") {
return 7
}
if matchCode(err, CodeOperationTimeout) {
return 124
}
return 1
}
// PrintError writes err to w (typically stderr) in dual mode:
// - text: code: msg\nhint: ...\nretry: ...
// - json/ndjson: {ok:false, error:{...}}
//
// 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) {
hint := typed.Hint
if hint == "" {
hint = defaultHint(typed.Code)
}
if hint != "" {
fmt.Fprintf(w, "hint: %s\n", hint)
}
retry := typed.RetryArgv
if len(retry) == 0 {
retry = defaultRetryArgv(typed.Code)
}
if len(retry) > 0 {
fmt.Fprintf(w, "retry: %s\n", strings.Join(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
// list / kb view / search) without per-command hint plumbing.
//
// Empty string for codes without a stable canonical hint.
func defaultHint(code ErrorCode) string {
switch code {
case CodeAuthUnauthenticated, CodeAuthBadCredential:
return "run `weknora auth login`"
case CodeAuthTokenExpired:
return "your session expired; run `weknora auth login` to re-authenticate"
case CodeAuthForbidden:
return "active profile lacks permission for this resource"
case CodeAuthCrossTenantBlocked, CodeAuthTenantMismatch:
return "verify tenant profile with `weknora auth status`"
case CodeNetworkError:
return "check base URL reachability with `weknora doctor`"
case CodeServerIncompatibleVersion:
return "run `weknora doctor` to see version skew details"
case CodeServerRateLimited:
return "rate-limited; retry after a few seconds"
case CodeServerTimeout:
return "request timed out; retry, or run `weknora doctor` to check connectivity"
case CodeResourceNotFound:
return "verify the resource ID and try again"
case CodeInputInvalidArgument, CodeInputMissingFlag:
return "see `weknora <command> --help` for valid usage"
case CodeInputConfirmationRequired:
return "high-risk write - re-run with -y/--yes after the user explicitly approves"
case CodeLocalKeychainDenied:
return "verify keyring access; falls back to file storage"
case CodeLocalConfigCorrupt:
return "remove ~/.config/weknora/config.yaml and re-run `weknora auth login`"
case CodeLocalFileIO:
return "check file permissions under $XDG_CONFIG_HOME/weknora/"
case CodeKBIDRequired:
return "run `weknora link` to bind this directory to a knowledge base, or pass --kb"
case CodeKBNotFound:
return "list available with `weknora kb list`"
case CodeProjectLinkCorrupt:
return "remove .weknora/project.yaml and run `weknora link` again"
case CodeUserAborted:
return "no action taken; pass -y/--yes to skip the confirmation prompt"
case CodeUploadFileNotFound:
return "verify the path is correct and readable"
case CodeSSEStreamAborted:
return "the streaming answer was cut off mid-flight; retry, or pass --format json to buffer the full response"
case CodeSessionCreateFailed:
return "could not create a chat session; pass --session to reuse an existing session"
case CodeOperationTimeout:
return "wait timed out; raise --timeout or check the underlying job"
case CodeOperationCancelled:
return "operation cancelled by signal (Ctrl-C / SIGTERM)"
}
return ""
}
// defaultRetryArgv returns canonical retry argv for known codes.
// nil for codes without a stable canonical retry.
// Symmetric counterpart to defaultHint.
func defaultRetryArgv(code ErrorCode) []string {
switch code {
case CodeAuthUnauthenticated, CodeAuthBadCredential, CodeAuthTokenExpired:
return []string{"weknora", "auth", "login"}
case CodeKBIDRequired:
return []string{"weknora", "link"}
case CodeNetworkError, CodeServerTimeout:
return []string{"weknora", "doctor"}
case CodeProjectLinkCorrupt:
return []string{"weknora", "link"} // re-bind the project to a KB
case CodeLocalConfigCorrupt:
// Recovery is two steps (delete config + re-login); the prose hint
// already spells it out, so the retry argv stays nil.
return nil
}
return nil
}
// boolPtr returns a pointer to b. Used by retryableForCode so the *bool can
// distinguish true / false / unknown(nil) on the wire.
func boolPtr(b bool) *bool { return &b }
// retryableForCode classifies whether re-running the SAME command may succeed.
// Pointer-to-true for transient failures, pointer-to-false for deterministic
// ones, nil (omitted) when genuinely unknown — notably CodeInternalError and
// any unlisted code (do NOT default to false).
func retryableForCode(code ErrorCode) *bool {
switch code {
// Transient — retrying the same command may succeed.
case CodeServerRateLimited, CodeServerTimeout, CodeNetworkError,
CodeOperationTimeout, CodeSSEStreamAborted:
return boolPtr(true)
// CodeServerError (generic 500) is deliberately NOT here: a 500 may be a
// transient server hiccup OR a deterministic server-side bug / mislabeled
// bad request, so blindly retrying risks a loop. Left unlisted → nil
// (unknown), so the agent decides rather than reading an optimistic true.
// Deterministic — same command will fail the same way.
case CodeAuthUnauthenticated, CodeAuthTokenExpired, CodeAuthBadCredential,
CodeAuthForbidden, CodeAuthCrossTenantBlocked, CodeAuthTenantMismatch,
CodeInputInvalidArgument, CodeInputMissingFlag,
CodeInputConfirmationRequired, CodeInputUnknownSubcommand,
CodeResourceNotFound, CodeResourceAlreadyExists, CodeResourceLocked,
CodeOperationFailed, CodeOperationCancelled, CodeUserAborted,
CodeUploadFileNotFound, CodeKBIDRequired, CodeKBNotFound,
CodeProjectLinkCorrupt, CodeLocalConfigCorrupt, CodeLocalKeychainDenied,
CodeLocalFileIO, CodeLocalProfileNotFound, CodeSessionCreateFailed,
CodeServerIncompatibleVersion:
return boolPtr(false)
}
// CodeInternalError and any unlisted code: genuinely unknown → omit.
return nil
}
+123
View File
@@ -0,0 +1,123 @@
package cmdutil
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestExitCode(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"nil success", nil, 0},
{"flag error", NewFlagError(errors.New("bad flag")), 2},
{"silent", SilentError, 1},
{"auth.* prefix", NewError(CodeAuthUnauthenticated, "x"), 3},
{"auth.token_expired", NewError(CodeAuthTokenExpired, "x"), 3},
{"resource.not_found", NewError(CodeResourceNotFound, "x"), 4},
{"input.* prefix", NewError(CodeInputInvalidArgument, "x"), 5},
{"input.missing_flag", NewError(CodeInputMissingFlag, "x"), 5},
{"server.rate_limited", NewError(CodeServerRateLimited, "x"), 6},
{"server.* prefix", NewError(CodeServerError, "x"), 7},
{"server.timeout", NewError(CodeServerTimeout, "x"), 7},
{"network.* prefix", NewError(CodeNetworkError, "x"), 7},
{"unknown error", errors.New("plain"), 1},
{"local.* prefix", NewError(CodeLocalConfigCorrupt, "x"), 1},
{"operation.timeout", NewError(CodeOperationTimeout, "timed out"), 124},
{"operation.failed → 1 (fall-through bucket)", NewError(CodeOperationFailed, "failed"), 1},
{"operation.cancelled → 1 (main overrides to 130 on signal-cancelled ctx)", NewError(CodeOperationCancelled, "cancelled"), 1},
{"server.session_create_failed → 1 (workflow, not transient)", NewError(CodeSessionCreateFailed, "x"), 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, ExitCode(tc.err))
})
}
}
func TestPrintError(t *testing.T) {
t.Run("nil is silent", func(t *testing.T) {
var buf bytes.Buffer
PrintError(&buf, nil)
assert.Empty(t, buf.String())
})
t.Run("SilentError is silent", func(t *testing.T) {
var buf bytes.Buffer
PrintError(&buf, SilentError)
assert.Empty(t, buf.String())
})
t.Run("typed error prints message", func(t *testing.T) {
var buf bytes.Buffer
PrintError(&buf, NewError(CodeAuthUnauthenticated, "no creds"))
assert.Contains(t, buf.String(), "auth.unauthenticated")
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").
WithRetryArgv([]string{"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_argv":["weknora","kb","delete","kb_x","-y"]`) {
t.Errorf("expected retry_argv; 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("text")
err := NewError(CodeInputConfirmationRequired, "kb delete kb_x requires confirmation").
WithHint("re-run with -y/--yes").
WithRetryArgv([]string{"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)
}
}
+395
View File
@@ -0,0 +1,395 @@
package cmdutil
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/projectlink"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// Factory is the dependency container injected at command construction. Each
// closure is lazy: --help / completion / `weknora version` must NOT trigger
// HTTP, keyring access, or filesystem I/O beyond the bare minimum.
//
// Four closures:
// - Config: parses ~/.config/weknora/config.yaml (no network)
// - Client: constructs the SDK client; only Secrets is sync.Once-cached,
// so callers should hold the returned *sdk.Client across
// multiple SDK calls within one invocation
// - Prompter: returns interactive prompter; agent mode returns AgentPrompter
// - Secrets: builds the OS keyring / file fallback credential store the
// first time it is requested (probing the keyring at startup
// would fork+exec on macOS and DBus-touch on Linux,
// defeating the lazy contract above).
//
// IOStreams is intentionally NOT a Factory closure - it is the package singleton
// iostreams.IO. The bar to add a new closure is at least 2 commands sharing the
// same dependency; resist factory bloat.
//
// Client returns a *sdk.Client (the WeKnora SDK). Commands that want narrow
// service interfaces declare them in their own files and let the real SDK
// satisfy them implicitly via duck typing.
type Factory struct {
Config func() (*config.Config, error)
Client func() (*sdk.Client, error)
Prompter func() prompt.Prompter
Secrets func() (secrets.Store, error)
// ProfileOverride, if non-empty, replaces config.CurrentProfile for this
// invocation only - set by the global --profile flag in PersistentPreRun.
// Buildable Config() / Client() honor it without writing to disk.
ProfileOverride string
}
// New constructs a production Factory wired to real config / SDK client.
//
// All closures are lazy: invoking --help, version, or shell completion runs
// none of them. Client and Secrets closures memoize via sync.Once so the
// SDK client is built (and the keyring is probed) at most once per process,
// even when Factory.ResolveKB internally calls f.Client() before the
// command's RunE calls it again - without this, name-resolved --kb paths
// would build two clients with two AuthRetryTransports holding independent
// token state.
func New() *Factory {
var (
secretsOnce sync.Once
secretsStore secrets.Store
secretsErr error
clientOnce sync.Once
client *sdk.Client
clientErr error
)
f := &Factory{}
f.Config = func() (*config.Config, error) {
cfg, err := config.Load()
if err != nil {
// Map raw fs / parse errors to typed codes so the stderr line
// doesn't surface bare `server.error` for what's actually a
// local IO / corrupt-config problem.
if errors.Is(err, config.ErrCorrupt) {
return nil, Wrapf(CodeLocalConfigCorrupt, err, "config malformed")
}
return nil, Wrapf(CodeLocalFileIO, err, "load config")
}
if f.ProfileOverride != "" {
cfg.CurrentProfile = f.ProfileOverride
}
return cfg, nil
}
f.Client = func() (*sdk.Client, error) {
clientOnce.Do(func() { client, clientErr = buildClient(f) })
return client, clientErr
}
f.Prompter = func() prompt.Prompter {
if iostreams.IO.IsStdoutTTY() && iostreams.IO.IsStderrTTY() {
return prompt.NewTTYPrompter()
}
return prompt.AgentPrompter{}
}
f.Secrets = func() (secrets.Store, error) {
secretsOnce.Do(func() {
secretsStore, secretsErr = secrets.NewBestEffortStore()
})
return secretsStore, secretsErr
}
return f
}
// buildClient resolves the active profile, loads the credentials from secrets,
// and constructs a *sdk.Client. Returns CodeAuthUnauthenticated when no
// credentials are available so the user gets the right hint to run
// `weknora auth login`.
func buildClient(f *Factory) (*sdk.Client, error) {
// Env-credential injection: WEKNORA_TOKEN (bearer) or WEKNORA_API_KEY, with
// WEKNORA_HOST (or the active profile's host), builds an ephemeral client
// that bypasses config.yaml + the keyring — the stateless headless / CI /
// agent path (no disk writes, no `auth login`).
if c, handled, err := buildClientFromEnv(f); handled {
return c, err
}
cfg, err := f.Config()
if err != nil {
return nil, err
}
profileName := cfg.CurrentProfile
if profileName == "" {
// Zero-state: no profile exists at all. The generic auth.unauthenticated
// default retry_argv is `auth login`, but that ALSO fails here (it needs
// an active profile) — an agent that execs retry_argv would loop. Point
// hint + retry_argv at the real first step: create a profile.
return nil, NewError(CodeAuthUnauthenticated, "no profile configured").
WithHint("add one first: `weknora profile add <name> --host <url> --use`, then `weknora auth login` (or set WEKNORA_API_KEY + WEKNORA_HOST for headless use)").
WithRetryArgv([]string{"weknora", "profile", "add", "--help"})
}
prof, ok := cfg.Profiles[profileName]
if !ok {
// If the user explicitly overrode the profile (via --profile flag or
// WEKNORA_PROFILE env), it's a bad argument - not a corrupt config file.
// The destructive "remove config.yaml" hint would be catastrophic for a typo.
if f.ProfileOverride != "" {
return nil, NewError(CodeInputInvalidArgument,
fmt.Sprintf("profile %q not configured", profileName)).
WithHint("list available profiles with `weknora profile list`").
WithRetryArgv([]string{"weknora", "profile", "list"})
}
// ProfileOverride is empty: config.CurrentProfile points at a missing entry.
// That's a genuinely corrupt config file.
return nil, NewError(CodeLocalConfigCorrupt, fmt.Sprintf("config references unknown profile %q", profileName))
}
if prof.Host == "" {
return nil, NewError(CodeLocalConfigCorrupt, fmt.Sprintf("profile %q has no host", profileName))
}
opts := []sdk.ClientOption{}
store, err := f.Secrets()
if err != nil {
return nil, Wrapf(CodeLocalKeychainDenied, err, "init secrets store")
}
// Only fetch the secrets the profile actually references. Skipping the
// unused fetch avoids a `security` exec (macOS) / DBus call (Linux) per
// authenticated invocation.
var accessToken string
if prof.TokenRef != "" {
if access, err := LoadSecret(store, profileName, "access"); err != nil {
return nil, err
} else if access != "" {
accessToken = access
opts = append(opts, sdk.WithBearerToken(access))
}
}
if prof.APIKeyRef != "" {
if apiKey, err := LoadSecret(store, profileName, "api_key"); err != nil {
return nil, err
} else if apiKey != "" {
opts = append(opts, sdk.WithAPIKey(apiKey))
}
}
// JWT profiles (have both access + refresh refs) get the transparent
// 401-retry transport: on the first 401 from a non-/auth/* endpoint, the
// transport reads the stored refresh token, calls /api/v1/auth/refresh,
// persists the new pair, and replays the original request with the new
// bearer. API-key profiles skip this (no refresh semantic) - a 401 from
// them propagates as auth.unauthenticated for the caller to handle.
if prof.TokenRef != "" && prof.RefreshRef != "" {
refreshFn := func(rctx context.Context) (string, error) {
return refreshAccessToken(rctx, store, prof.Host, profileName)
}
opts = append(opts, sdk.WithTransport(
NewAuthRetryTransport(http.DefaultTransport, accessToken, refreshFn),
))
}
// prof.TenantID is intentionally NOT injected as X-Tenant-ID. Servers derive
// tenant from the credential itself (JWT claim or API key prefix); the
// header is only meaningful for explicit cross-tenant switching by users
// with CanAccessAllTenants. Auto-mirroring the persisted tenant from config
// breaks that contract - explicit cross-tenant flags would be required
// before sending it. `tenant_id` stays in config for `auth status` display only.
return sdk.NewClient(prof.Host, opts...), nil
}
// EnvCredential reports whether stateless env credentials are in effect and
// which kind. Used by `auth status` / `config view` so their host / profile
// output reflects that the env credential (and WEKNORA_HOST) — not the config
// profile — is what actually authenticated the client. Mirrors buildClientFromEnv's
// precedence (WEKNORA_TOKEN wins over WEKNORA_API_KEY).
func EnvCredential() (active bool, kind string) {
if strings.TrimSpace(os.Getenv("WEKNORA_TOKEN")) != "" {
return true, "WEKNORA_TOKEN"
}
if strings.TrimSpace(os.Getenv("WEKNORA_API_KEY")) != "" {
return true, "WEKNORA_API_KEY"
}
return false, ""
}
// buildClientFromEnv builds an ephemeral SDK client from WEKNORA_TOKEN (bearer
// JWT) or WEKNORA_API_KEY when either is set, bypassing config.yaml + the
// keyring entirely — the stateless path for headless / CI / agent use. Returns
// handled=false (fall through to the profile path) when neither var is set.
//
// Host resolution: WEKNORA_HOST, else the active profile's host (so env creds
// can target an already-configured host without re-specifying it). When a token
// is supplied, no 401→refresh transport is attached — env creds are ephemeral,
// so a 401 propagates for the caller to supply a fresh token. WEKNORA_TOKEN
// wins over WEKNORA_API_KEY if both are set.
func buildClientFromEnv(f *Factory) (client *sdk.Client, handled bool, err error) {
token := strings.TrimSpace(os.Getenv("WEKNORA_TOKEN"))
apiKey := strings.TrimSpace(os.Getenv("WEKNORA_API_KEY"))
if token == "" && apiKey == "" {
return nil, false, nil
}
host := strings.TrimSpace(os.Getenv("WEKNORA_HOST"))
if host == "" {
// Best-effort fallback to the active profile's host; ignore config
// errors so env creds + WEKNORA_HOST stay usable with no config at all.
if cfg, cerr := f.Config(); cerr == nil && cfg != nil {
if prof, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
host = prof.Host
}
}
}
if host == "" {
return nil, true, NewError(CodeInputInvalidArgument,
"WEKNORA_TOKEN / WEKNORA_API_KEY is set but no host is available").
WithHint("set WEKNORA_HOST (e.g. https://kb.example.com) or configure a profile host")
}
if token != "" {
return sdk.NewClient(host, sdk.WithBearerToken(token)), true, nil
}
return sdk.NewClient(host, sdk.WithAPIKey(apiKey)), true, nil
}
// AddKBFlag registers the standard `--kb` flag that ResolveKB reads. Use this
// instead of duplicating the flag declaration in every command that scopes to
// a knowledge base — one source of truth for flag name and help text.
func AddKBFlag(cmd *cobra.Command) {
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
}
// AddIgnoredKBFlag registers a no-op --kb on id-addressed commands (doc view /
// wait, chunk list / view). Those resolve a globally-unique doc/chunk id, so
// --kb is redundant — but an agent flowing from `doc upload --kb X` naturally
// carries it, and rejecting it with exit 2 is pure friction. Accepted and
// ignored; declared here so schema still lists it truthfully.
func AddIgnoredKBFlag(cmd *cobra.Command) {
cmd.Flags().String("kb", "", "Ignored — the id argument is globally unique; accepted for symmetry with `doc list`/`doc upload` so a carried-over --kb doesn't error")
}
// ResolveKB returns the active KB id for the running command, applying the
// 4-level fallback chain (highest to lowest):
// 1. --kb flag (kb_<...> id passed through; anything else resolved via
// ListKnowledgeBases as a name → id lookup)
// 2. WEKNORA_KB_ID env (always an explicit id)
// 3. .weknora/project.yaml (walk-up from cwd)
// 4. error: kb required
func (f *Factory) ResolveKB(cmd *cobra.Command) (string, error) {
if v, _ := cmd.Flags().GetString("kb"); v != "" {
if IsKBID(v) {
return v, nil
}
c, err := f.Client()
if err != nil {
return "", err
}
return ResolveKBNameToID(cmd.Context(), c, v)
}
return f.resolveKBFromEnvOrLink()
}
// ResolveKBLocal mirrors ResolveKB but never calls the SDK. When --kb is a
// name (not a UUID) it returns the raw value as-is instead of looking up the
// id server-side. Intended for dry-run paths where SDK side effects must be
// avoided; the dry-run plan reports the user-supplied identifier verbatim,
// and the live execution path resolves the name → id at call time.
func (f *Factory) ResolveKBLocal(cmd *cobra.Command) (string, error) {
if v, _ := cmd.Flags().GetString("kb"); v != "" {
return v, nil
}
return f.resolveKBFromEnvOrLink()
}
// resolveKBFromEnvOrLink is the shared fallback tail of ResolveKB /
// ResolveKBLocal (which differ only in how they treat the --kb flag): it reads
// WEKNORA_KB_ID, then the walk-up .weknora/project.yaml link, and returns
// CodeKBIDRequired when neither is set. Never calls the SDK.
func (f *Factory) resolveKBFromEnvOrLink() (string, error) {
if v := os.Getenv("WEKNORA_KB_ID"); v != "" {
return v, nil
}
cwd, err := os.Getwd()
if err == nil {
if path, found, derr := projectlink.Discover(cwd); derr == nil && found {
p, lerr := projectlink.Load(path)
if lerr != nil {
return "", Wrapf(CodeProjectLinkCorrupt, lerr, "read project link")
}
if p.KBID != "" {
return p.KBID, nil
}
}
}
return "", NewError(CodeKBIDRequired, "kb is required")
}
// ApplyLogLevel resolves --log-level / WEKNORA_LOG_LEVEL (in priority order)
// and applies the result to the SDK's debug logger. Intended to be called
// from the root command's PersistentPreRunE so the resolved level is in
// effect before any SDK call.
//
// Returns a typed error if the user passed an explicit --log-level with
// an invalid value — matches the strictness of --format validation
// (env values stay silent-fallthrough; flag values are strict).
func (f *Factory) ApplyLogLevel(cmd *cobra.Command, stderr io.Writer) error {
if cmd != nil {
if fl := cmd.Flags().Lookup("log-level"); fl != nil && fl.Changed {
if !IsValidLogLevel(fl.Value.String()) {
return NewFlagError(fmt.Errorf(
"invalid --log-level %q: must be error | warn | info | debug", fl.Value.String()))
}
}
}
level, _ := ResolveLogLevel(cmd, stderr)
sdk.SetDebugLevel(level)
return nil
}
// LoadSecret fetches a named secret for the given profile from the keyring.
// Returns ("", nil) when the secret is absent (ErrNotFound); a real keyring
// access failure surfaces as CodeLocalKeychainDenied. Used by buildClient
// to assemble SDK auth options and by `auth token` to expose the raw
// credential for shell scripting.
func LoadSecret(store secrets.Store, profile, key string) (string, error) {
v, err := store.Get(profile, key)
if errors.Is(err, secrets.ErrNotFound) {
return "", nil
}
if err != nil {
return "", Wrapf(CodeLocalKeychainDenied, err, "load %s", key)
}
return v, nil
}
// refreshAccessToken is the closure target injected into AuthRetryTransport's
// refreshFn. A fresh SDK Client is built here rather than reusing the one
// being constructed - that one is itself wrapped by the transport, which
// would recurse on refresh. The refresh endpoint is unauthenticated apart
// from the refresh token in the body, so no credential options are needed.
func refreshAccessToken(ctx context.Context, store secrets.Store, host, profileName string) (string, error) {
return RefreshAndPersist(ctx, store, sdk.NewClient(host), profileName)
}
// ActiveProfile returns the resolved profile name for this invocation:
// 1. ProfileOverride (set by --profile flag in root PersistentPreRunE)
// 2. WEKNORA_PROFILE env var
// 3. Config's CurrentProfile (the persisted active profile name)
// 4. Empty string when nothing is configured (envelope omits the field).
func (f *Factory) ActiveProfile() string {
if f.ProfileOverride != "" {
return f.ProfileOverride
}
if v := os.Getenv("WEKNORA_PROFILE"); v != "" {
return v
}
if f.Config == nil {
return ""
}
cfg, err := f.Config()
if err != nil || cfg == nil {
return ""
}
return cfg.CurrentProfile
}
+477
View File
@@ -0,0 +1,477 @@
package cmdutil
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/projectlink"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// TestFactory_Lazy ensures none of the closures execute work at construction
// time - `--help` / `completion` must not trigger HTTP / keyring access.
func TestFactory_Lazy(t *testing.T) {
var configCalls, clientCalls, prompterCalls int
f := &Factory{
Config: func() (*config.Config, error) {
configCalls++
return &config.Config{}, nil
},
Client: func() (*sdk.Client, error) {
clientCalls++
return nil, nil
},
Prompter: func() prompt.Prompter {
prompterCalls++
return prompt.AgentPrompter{}
},
}
// Asserting on closure presence - none should have run yet.
assert.Equal(t, 0, configCalls)
assert.Equal(t, 0, clientCalls)
assert.Equal(t, 0, prompterCalls)
// Smoke: each closure runs exactly once when called.
_, err := f.Config()
require.NoError(t, err)
assert.Equal(t, 1, configCalls)
_, _ = f.Client()
assert.Equal(t, 1, clientCalls)
_ = f.Prompter()
assert.Equal(t, 1, prompterCalls)
}
// TestNew_FoundationDefaults verifies the production New() returns a usable
// Factory and that Client surfaces auth.unauthenticated when no current
// profile is configured (the precondition for `weknora auth login`).
func TestNew_FoundationDefaults(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // empty config → no current profile
f := New()
require.NotNil(t, f)
require.NotNil(t, f.Config)
require.NotNil(t, f.Client)
require.NotNil(t, f.Prompter)
require.NotNil(t, f.Secrets)
_, err := f.Client()
require.Error(t, err)
var typed *Error
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error")
assert.Equal(t, CodeAuthUnauthenticated, typed.Code)
}
// TestFactory_ProfileOverride verifies the global --profile flag mechanism:
// f.ProfileOverride replaces config.CurrentProfile for this invocation only,
// without writing to disk.
func TestFactory_ProfileOverride(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
// Seed config with two profiles; CurrentProfile = "default"
cfgPath := dir + "/weknora/config.yaml"
require.NoError(t, os.MkdirAll(dir+"/weknora", 0o700))
require.NoError(t, os.WriteFile(cfgPath, []byte(`
current_profile: default
profiles:
default:
host: https://default.example
other:
host: https://other.example
`), 0o600))
f := New()
t.Run("no override: returns CurrentProfile from disk", func(t *testing.T) {
f.ProfileOverride = ""
cfg, err := f.Config()
require.NoError(t, err)
assert.Equal(t, "default", cfg.CurrentProfile)
})
t.Run("override applied: ProfileOverride wins over disk", func(t *testing.T) {
f.ProfileOverride = "other"
cfg, err := f.Config()
require.NoError(t, err)
assert.Equal(t, "other", cfg.CurrentProfile)
})
t.Run("override does not persist to disk", func(t *testing.T) {
// Reload from disk: should still be "default" (the original).
raw, err := os.ReadFile(cfgPath)
require.NoError(t, err)
assert.Contains(t, string(raw), "current_profile: default")
})
}
// TestTypedPredicates exercises the namespace and code matchers.
func TestTypedPredicates(t *testing.T) {
t.Run("IsAuthError matches auth.* prefix", func(t *testing.T) {
err := NewError(CodeAuthUnauthenticated, "no creds")
assert.True(t, IsAuthError(err))
assert.False(t, IsNotFound(err))
})
t.Run("IsNotFound matches resource.not_found exactly", func(t *testing.T) {
err := NewError(CodeResourceNotFound, "kb missing")
assert.True(t, IsNotFound(err))
assert.False(t, IsTransient(err))
})
t.Run("IsTransient matches network.* and server.timeout / rate_limited", func(t *testing.T) {
assert.True(t, IsTransient(NewError(CodeNetworkError, "")))
assert.True(t, IsTransient(NewError(CodeServerTimeout, "")))
assert.True(t, IsTransient(NewError(CodeServerRateLimited, "")))
assert.False(t, IsTransient(NewError(CodeServerError, "")))
})
t.Run("predicates walk the wrap chain", func(t *testing.T) {
inner := NewError(CodeAuthTokenExpired, "expired")
wrapped := Wrapf(CodeServerError, inner, "while calling foo")
// IsAuthExpired matches the wrapped *Error first; outer Wrapf has
// CodeServerError so the predicate returns false on the outer match.
// This documents current behavior: predicates report the first *Error
// in the chain, not deep walks.
assert.False(t, IsAuthExpired(wrapped))
// Direct match works.
assert.True(t, IsAuthExpired(inner))
})
t.Run("non-typed errors are never matched", func(t *testing.T) {
assert.False(t, IsAuthError(errors.New("plain error")))
assert.False(t, IsNotFound(nil))
})
}
// TestError_Format checks the Error/Unwrap surface.
func TestError_Format(t *testing.T) {
cause := errors.New("dial tcp: refused")
e := Wrapf(CodeNetworkError, cause, "connect to %s", "host")
assert.Contains(t, e.Error(), "network.error")
assert.Contains(t, e.Error(), "connect to host")
assert.Contains(t, e.Error(), "dial tcp: refused")
assert.Same(t, cause, errors.Unwrap(e))
}
func memSecretsFn(s *secrets.MemStore) func() (secrets.Store, error) {
return func() (secrets.Store, error) { return s, nil }
}
func TestBuildClient_NoCurrentProfile(t *testing.T) {
f := &Factory{
Config: func() (*config.Config, error) { return &config.Config{}, nil },
Secrets: memSecretsFn(secrets.NewMemStore()),
}
_, err := buildClient(f)
require.Error(t, err)
var typed *Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, CodeAuthUnauthenticated, typed.Code)
// Zero-state retry_argv must NOT be the generic `auth login` (which itself
// fails with no profile → an agent execing retry_argv would loop); it must
// point at profile creation instead.
detail := ErrorToDetail(err)
assert.NotEqual(t, []string{"weknora", "auth", "login"}, detail.RetryArgv,
"zero-state retry_argv must not loop back to auth login")
assert.Contains(t, detail.RetryArgv, "profile", "zero-state retry_argv should point at profile setup")
}
func TestBuildClient_UnknownContext(t *testing.T) {
f := &Factory{
Config: func() (*config.Config, error) {
return &config.Config{CurrentProfile: "ghost"}, nil
},
Secrets: memSecretsFn(secrets.NewMemStore()),
}
_, err := buildClient(f)
require.Error(t, err)
var typed *Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, CodeLocalConfigCorrupt, typed.Code)
}
func TestBuildClient_MissingHost(t *testing.T) {
f := &Factory{
Config: func() (*config.Config, error) {
return &config.Config{
CurrentProfile: "p",
Profiles: map[string]config.Profile{"p": {Host: ""}},
}, nil
},
Secrets: memSecretsFn(secrets.NewMemStore()),
}
_, err := buildClient(f)
require.Error(t, err)
var typed *Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, CodeLocalConfigCorrupt, typed.Code)
}
func TestBuildClient_HappyPath(t *testing.T) {
store := secrets.NewMemStore()
require.NoError(t, store.Set("p", "access", "jwt"))
require.NoError(t, store.Set("p", "api_key", "sk-x"))
f := &Factory{
Config: func() (*config.Config, error) {
return &config.Config{
CurrentProfile: "p",
Profiles: map[string]config.Profile{
"p": {
Host: "https://kb.example.com",
TenantID: 7,
TokenRef: "mem://p/access",
APIKeyRef: "mem://p/api_key",
},
},
}, nil
},
Secrets: memSecretsFn(store),
}
cli, err := buildClient(f)
require.NoError(t, err)
require.NotNil(t, cli)
}
func TestBuildClient_SkipsUnreferencedSecrets(t *testing.T) {
// If the profile doesn't list APIKeyRef, buildClient must not call
// Get(api_key) - a perf invariant: avoid keychain trips for unused creds.
store := &countingSecrets{MemStore: secrets.NewMemStore()}
require.NoError(t, store.Set("p", "access", "jwt"))
f := &Factory{
Config: func() (*config.Config, error) {
return &config.Config{
CurrentProfile: "p",
Profiles: map[string]config.Profile{
"p": {Host: "https://x", TokenRef: "mem://p/access"},
},
}, nil
},
Secrets: func() (secrets.Store, error) { return store, nil },
}
_, err := buildClient(f)
require.NoError(t, err)
assert.Equal(t, 1, store.gets, "must fetch only access; api_key was not referenced")
}
// countingSecrets wraps MemStore to count Get invocations.
type countingSecrets struct {
*secrets.MemStore
gets int
}
func (c *countingSecrets) Get(ctx, key string) (string, error) {
c.gets++
return c.MemStore.Get(ctx, key)
}
// makeResolveKBCmd builds a minimal cobra.Command carrying the single --kb
// local flag so cmd.Flags().GetString lookups in ResolveKB exercise the flag
// path. The single flag accepts either a kb_<id> or a name.
func makeResolveKBCmd(t *testing.T, kb string) *cobra.Command {
t.Helper()
c := &cobra.Command{Use: "x"}
c.Flags().String("kb", "", "")
if kb != "" {
require.NoError(t, c.Flags().Set("kb", kb))
}
c.SetContext(context.Background())
return c
}
// resolveKBChdir switches cwd to dir for the duration of t (auto-restored).
// ResolveKB walks up from os.Getwd() so tests must isolate cwd.
func resolveKBChdir(t *testing.T, dir string) {
t.Helper()
prev, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(dir))
t.Cleanup(func() { _ = os.Chdir(prev) })
}
// fakeKBServer returns an httptest server that answers GET /api/v1/knowledge-bases
// with kbs (KnowledgeBaseListResponse), so a real *sdk.Client can talk to it.
func fakeKBServer(t *testing.T, kbs []sdk.KnowledgeBase) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/knowledge-bases", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(sdk.KnowledgeBaseListResponse{Success: true, Data: kbs})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// TestFactory_ActiveProfile_EnvVarFallback verifies WEKNORA_PROFILE is honoured
// when no override or config is present.
func TestFactory_ActiveProfile_EnvVarFallback(t *testing.T) {
t.Setenv("WEKNORA_PROFILE", "staging")
f := &Factory{} // no override, no config
if got := f.ActiveProfile(); got != "staging" {
t.Errorf("expected env fallback to staging; got %q", got)
}
}
// TestFactory_ActiveProfile_OverrideWinsEnv verifies ProfileOverride takes
// priority over the WEKNORA_PROFILE env var.
func TestFactory_ActiveProfile_OverrideWinsEnv(t *testing.T) {
t.Setenv("WEKNORA_PROFILE", "staging")
f := &Factory{ProfileOverride: "prod"}
if got := f.ActiveProfile(); got != "prod" {
t.Errorf("override should win over env; got %q", got)
}
}
// TestResolveKB_Chain exercises the 4-level fallback chain. Each sub-test
// isolates cwd / env / closure from the others.
func TestResolveKB_Chain(t *testing.T) {
t.Run("flag_kb_id_wins", func(t *testing.T) {
// UUID form on --kb → pass-through; no SDK call, no env, no disk.
t.Setenv("WEKNORA_KB_ID", "kb_env_should_lose")
dir := t.TempDir()
resolveKBChdir(t, dir)
// Drop a project link too - must be ignored.
require.NoError(t, projectlink.Save(filepath.Join(dir, ".weknora", "project.yaml"), &projectlink.Project{KBID: "kb_disk_should_lose"}))
clientCalls := 0
f := &Factory{
Client: func() (*sdk.Client, error) {
clientCalls++
return nil, errors.New("must not be called")
},
}
got, err := f.ResolveKB(makeResolveKBCmd(t, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"))
require.NoError(t, err)
assert.Equal(t, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", got)
assert.Equal(t, 0, clientCalls)
})
t.Run("flag_kb_name_resolves", func(t *testing.T) {
t.Setenv("WEKNORA_KB_ID", "")
srv := fakeKBServer(t, []sdk.KnowledgeBase{
{ID: "kb_a", Name: "foo"},
{ID: "kb_b", Name: "bar"},
})
f := &Factory{
Client: func() (*sdk.Client, error) { return sdk.NewClient(srv.URL), nil },
}
got, err := f.ResolveKB(makeResolveKBCmd(t, "foo"))
require.NoError(t, err)
assert.Equal(t, "kb_a", got)
})
t.Run("flag_kb_name_not_found", func(t *testing.T) {
t.Setenv("WEKNORA_KB_ID", "")
srv := fakeKBServer(t, []sdk.KnowledgeBase{{ID: "kb_a", Name: "foo"}})
f := &Factory{
Client: func() (*sdk.Client, error) { return sdk.NewClient(srv.URL), nil },
}
_, err := f.ResolveKB(makeResolveKBCmd(t, "missing"))
require.Error(t, err)
var typed *Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, CodeKBNotFound, typed.Code)
})
t.Run("env_var", func(t *testing.T) {
// No flag, env wins over disk.
t.Setenv("WEKNORA_KB_ID", "kb_env")
dir := t.TempDir()
resolveKBChdir(t, dir)
require.NoError(t, projectlink.Save(filepath.Join(dir, ".weknora", "project.yaml"), &projectlink.Project{KBID: "kb_disk_should_lose"}))
f := &Factory{}
got, err := f.ResolveKB(makeResolveKBCmd(t, ""))
require.NoError(t, err)
assert.Equal(t, "kb_env", got)
})
t.Run("project_link_walk_up", func(t *testing.T) {
t.Setenv("WEKNORA_KB_ID", "")
root := t.TempDir()
require.NoError(t, projectlink.Save(filepath.Join(root, ".weknora", "project.yaml"), &projectlink.Project{KBID: "kb_proj"}))
// Run from a deep child to exercise walk-up.
deep := filepath.Join(root, "a", "b", "c")
require.NoError(t, os.MkdirAll(deep, 0o755))
resolveKBChdir(t, deep)
f := &Factory{}
got, err := f.ResolveKB(makeResolveKBCmd(t, ""))
require.NoError(t, err)
assert.Equal(t, "kb_proj", got)
})
t.Run("none", func(t *testing.T) {
// No flag, no env, no project link → CodeKBIDRequired.
t.Setenv("WEKNORA_KB_ID", "")
dir := t.TempDir()
resolveKBChdir(t, dir)
f := &Factory{}
_, err := f.ResolveKB(makeResolveKBCmd(t, ""))
require.Error(t, err)
var typed *Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, CodeKBIDRequired, typed.Code)
})
}
// TestBuildClientFromEnv covers the stateless env-credential path: WEKNORA_TOKEN
// / WEKNORA_API_KEY build an ephemeral client with no config/keyring access.
func TestBuildClientFromEnv(t *testing.T) {
emptyCfg := &Factory{Config: func() (*config.Config, error) { return &config.Config{}, nil }}
t.Run("no env vars falls through to profile path", func(t *testing.T) {
t.Setenv("WEKNORA_TOKEN", "")
t.Setenv("WEKNORA_API_KEY", "")
c, handled, err := buildClientFromEnv(emptyCfg)
assert.False(t, handled, "no env creds must fall through")
assert.Nil(t, c)
assert.NoError(t, err)
})
t.Run("api key + WEKNORA_HOST builds a client", func(t *testing.T) {
t.Setenv("WEKNORA_TOKEN", "")
t.Setenv("WEKNORA_API_KEY", "sk-test")
t.Setenv("WEKNORA_HOST", "https://kb.example.com")
c, handled, err := buildClientFromEnv(emptyCfg)
assert.True(t, handled)
require.NoError(t, err)
assert.NotNil(t, c)
})
t.Run("token set but no host is a typed input error", func(t *testing.T) {
t.Setenv("WEKNORA_API_KEY", "")
t.Setenv("WEKNORA_TOKEN", "jwt-token")
t.Setenv("WEKNORA_HOST", "")
c, handled, err := buildClientFromEnv(emptyCfg)
assert.True(t, handled, "env creds set → handled even on host error")
assert.Nil(t, c)
var ce *Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, CodeInputInvalidArgument, ce.Code)
})
t.Run("host falls back to the active profile when WEKNORA_HOST unset", func(t *testing.T) {
t.Setenv("WEKNORA_API_KEY", "")
t.Setenv("WEKNORA_TOKEN", "jwt-token")
t.Setenv("WEKNORA_HOST", "")
f := &Factory{Config: func() (*config.Config, error) {
return &config.Config{
CurrentProfile: "p",
Profiles: map[string]config.Profile{"p": {Host: "https://prof.example.com"}},
}, nil
}}
c, handled, err := buildClientFromEnv(f)
assert.True(t, handled)
require.NoError(t, err, "should use the active profile's host")
assert.NotNil(t, c)
})
}
+191
View File
@@ -0,0 +1,191 @@
package cmdutil
import (
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/output"
)
// FormatMode is the resolved --format value (typed enum).
type FormatMode string
const (
FormatText FormatMode = "text"
FormatJSON FormatMode = "json"
FormatNDJSON FormatMode = "ndjson"
)
// DefaultFormatMode is the mode used when neither --format nor WEKNORA_FORMAT
// is set. Single source of truth shared by FormatOptions.ResolveDefault and
// cmd.resolveFormatEarly (the early cobra-parse-error path) so the two cannot
// drift on what "no flag" defaults to.
const DefaultFormatMode = FormatJSON
// FormatOptions captures the resolved --format + --jq state for a command.
// Mode is one of FormatText / FormatJSON / FormatNDJSON, or "" before
// ResolveDefault has been called.
type FormatOptions struct {
Mode FormatMode
JQ string
TTY bool // ResolveDefault populates; Emit reads for indent decision
}
// AddFormatFlag attaches the --jq projection field hints (in cmd.Long) for
// commands that honor --format. The flags themselves are registered as
// persistent globals at the root, so this helper no longer re-registers
// them — it only appends documentation. Callers that don't need field
// hints can skip this call entirely.
func AddFormatFlag(cmd *cobra.Command, fieldHints ...string) {
if len(fieldHints) > 0 {
sorted := append([]string(nil), fieldHints...)
sort.Strings(sorted)
// Fields live under .data in the {ok,data,meta} envelope, so --jq must
// be rooted there: `--jq '.data.<field>'` (object) or
// `--jq '.data[].<field>'` (list). A bare `--jq '.<field>'` matches the
// envelope top level and silently returns null — spell out the path so
// agents don't ship broken projections.
hdr := "\n\nJSON fields available under .data (project with --jq '.data.<field>', or '.data[].<field>' for lists):\n " +
strings.Join(sorted, "\n ")
if cmd.Long != "" {
cmd.Long += hdr
} else {
cmd.Long = strings.TrimSpace(cmd.Short) + hdr
}
}
}
// CheckFormatFlag resolves --format + --jq from cmd. Returns:
// - (*FormatOptions{Mode:""}, nil) flag not set; caller should call ResolveDefault
// - (*FormatOptions{Mode:v,JQ:q}, nil) valid values
// - (nil, *FlagError) invalid --format, or --jq with explicit --format text
//
// --jq with --format unset is accepted: ResolveDefault below will promote
// the mode to FormatJSON so the filter has somewhere to apply.
func CheckFormatFlag(cmd *cobra.Command) (*FormatOptions, error) {
fopts := &FormatOptions{}
if f := cmd.Flags().Lookup("format"); f != nil {
v := f.Value.String()
switch v {
case "":
// unset; caller calls ResolveDefault
case "text", "json", "ndjson":
fopts.Mode = FormatMode(v)
default:
return nil, NewFlagError(fmt.Errorf("invalid --format %q: must be text | json | ndjson", v))
}
}
if f := cmd.Flags().Lookup("jq"); f != nil {
fopts.JQ = f.Value.String()
}
// --jq only meaningful for JSON-shaped output. Reject the explicit
// `--format text --jq ...` combination; the `--jq` with --format unset
// case is handled by ResolveDefault.
if fopts.JQ != "" && fopts.Mode == FormatText {
return nil, NewFlagError(errors.New("--jq requires --format json|ndjson"))
}
return fopts, nil
}
// WantsJSON reports whether the resolved mode is JSON or NDJSON. Used by
// callers to choose between the JSON emit path and text rendering.
func (o *FormatOptions) WantsJSON() bool {
return o.Mode == FormatJSON || o.Mode == FormatNDJSON
}
// Emit serializes data wrapped in the success envelope (FormatJSON
// path) or as bare NDJSON lines (FormatNDJSON path). meta is optional
// (pass nil for mutation commands without batch counts).
//
// FormatJSON path: envelope is {ok:true, data:..., meta?:..., profile?:...}.
// Indent is determined by o.TTY (populated by ResolveDefault).
// When o.JQ is set, jq evaluates against the full envelope JSON, so users
// project with ".data[]", ".meta.count", etc.
//
// FormatNDJSON path: emits one bare JSON object per line (no envelope).
// Matches the NDJSON event-passthrough contract used by streaming commands.
//
// FormatText path returns an error so a missed dispatch surfaces loudly.
func (o *FormatOptions) Emit(w io.Writer, data any, meta *output.Meta) error {
switch o.Mode {
case FormatJSON:
if o.JQ != "" {
return mapJQError(format.WriteJSONFiltered(w, output.NewEnvelope(data, meta, globalProfile), nil, o.JQ))
}
return output.WriteEnvelope(w, data, meta, o.TTY, globalProfile)
case FormatNDJSON:
if o.JQ != "" {
return mapJQError(format.WriteJSONFiltered(w, data, nil, o.JQ))
}
return format.WriteNDJSON(w, data)
case FormatText:
return fmt.Errorf("FormatOptions.Emit: cannot emit text mode as JSON; caller must render human-readable separately")
default:
return fmt.Errorf("FormatOptions.Emit: unknown mode %q", o.Mode)
}
}
// mapJQError converts a failure rooted in the user-supplied --jq expression
// (format.JQError) into a typed input.invalid_argument error (exit 5) so agents
// fix the expression instead of treating a bad --jq as a CLI bug. Non-jq errors
// (e.g. an internal serialization fault) pass through unchanged.
func mapJQError(err error) error {
if err == nil {
return nil
}
var jqe *format.JQError
if errors.As(err, &jqe) {
return NewError(CodeInputInvalidArgument, jqe.Error()).
WithHint("invalid --jq expression; see https://jqlang.github.io/jq/manual/")
}
return err
}
// ResolveDefault fills in Mode when the caller has not explicitly set it:
// - Mode defaults to FormatJSON, regardless of TTY
// - TTY only affects the indent decision (auto-indent in TTY; compact in pipe)
// - For human-readable rendering, pass --format text explicitly
//
// JSON-always (not a TTY switch to text on a terminal) is deliberate: an
// agent-first CLI values output predictability over terminal ergonomics, so
// the default never depends on whether stdout is a TTY. Humans opt into
// human-readable output with `--format text`.
func (o *FormatOptions) ResolveDefault(tty bool) {
o.TTY = tty
// Apply WEKNORA_FORMAT before the hard default so the documented
// precedence holds: explicit --format (already set on o.Mode by
// CheckFormatFlag) > WEKNORA_FORMAT > DefaultFormatMode. FromEnv is a
// no-op when --format was passed. Folded in here because nearly every
// command calls ResolveDefault but only a couple called FromEnv, so the
// env var was silently ignored on success output across the CLI.
o.FromEnv()
if o.Mode == "" {
o.Mode = DefaultFormatMode
}
}
// FromEnv reads WEKNORA_FORMAT and applies it when Mode hasn't been set
// by --format. ResolveDefault now calls this, so commands get the env var
// applied automatically; explicit callers (e.g. the root PersistentPreRunE,
// which resolves the error-envelope mode before any command RunE) remain
// valid and idempotent.
//
// Invalid env values are silently ignored (the user's --format on a
// later invocation will still take precedence).
func (o *FormatOptions) FromEnv() {
if o.Mode != "" {
return
}
v := os.Getenv("WEKNORA_FORMAT")
switch v {
case "text", "json", "ndjson":
o.Mode = FormatMode(v)
}
}
@@ -0,0 +1,88 @@
package cmdutil_test
import (
"bytes"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/output"
)
func TestEmit_WrapsInEnvelope(t *testing.T) {
var buf bytes.Buffer
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
data := []map[string]string{{"id": "kb_x"}, {"id": "kb_y"}}
if err := fopts.Emit(&buf, data, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := buf.String()
if !strings.Contains(got, `"ok":true`) {
t.Errorf("expected envelope ok:true; got %q", got)
}
if !strings.Contains(got, `"data":[`) {
t.Errorf("expected data array; got %q", got)
}
}
func TestEmit_WithMeta(t *testing.T) {
var buf bytes.Buffer
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
meta := &output.Meta{Count: output.IntPtr(2), HasMore: true}
if err := fopts.Emit(&buf, []string{"a", "b"}, meta); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := buf.String()
if !strings.Contains(got, `"count":2`) {
t.Errorf("expected meta.count; got %q", got)
}
if !strings.Contains(got, `"has_more":true`) {
t.Errorf("expected meta.has_more; got %q", got)
}
}
func TestEmit_NDJSON_NoEnvelope(t *testing.T) {
// NDJSON path is event-passthrough; no envelope wrapping.
var buf bytes.Buffer
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
data := []map[string]string{{"id": "a"}, {"id": "b"}}
if err := fopts.Emit(&buf, data, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := buf.String()
if strings.Contains(got, `"ok":true`) {
t.Errorf("NDJSON path should not wrap in envelope; got %q", got)
}
// Expect 2 lines, one per item
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
if len(lines) != 2 {
t.Errorf("expected 2 NDJSON lines; got %d: %q", len(lines), got)
}
}
func TestEmit_TTYIndents(t *testing.T) {
var buf bytes.Buffer
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, TTY: true}
if err := fopts.Emit(&buf, map[string]string{"id": "x"}, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := buf.String()
if !strings.Contains(got, "\n \"") {
t.Errorf("expected indented multi-line output (TTY=true); got %q", got)
}
}
func TestEmit_PopulatesProfile(t *testing.T) {
cmdutil.SetProfile("staging")
t.Cleanup(func() { cmdutil.SetProfile("") })
var buf bytes.Buffer
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := fopts.Emit(&buf, map[string]string{"id": "x"}, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := buf.String()
if !strings.Contains(got, `"profile":"staging"`) {
t.Errorf("expected profile:staging in envelope; got %q", got)
}
}
+276
View File
@@ -0,0 +1,276 @@
package cmdutil
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCheckFormatFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantMode FormatMode // FormatText | FormatJSON | FormatNDJSON | "" (unset)
wantErr bool
}{
// Unset → Mode is "" (caller resolves default via ResolveDefault).
{"default", []string{}, "", false},
{"explicit text", []string{"--format", "text"}, FormatText, false},
{"json", []string{"--format", "json"}, FormatJSON, false},
{"ndjson", []string{"--format", "ndjson"}, FormatNDJSON, false},
{"invalid value", []string{"--format", "yaml"}, "", true},
{"human rejected", []string{"--format", "human"}, "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{}
// --format / --jq are persistent root flags in production; register
// them on this bare cmd so the test can exercise CheckFormatFlag in
// isolation.
cmd.PersistentFlags().String("format", "", "")
cmd.PersistentFlags().String("jq", "", "")
cmd.SetArgs(tc.args)
cmd.RunE = func(c *cobra.Command, _ []string) error {
opts, err := CheckFormatFlag(c)
if (err != nil) != tc.wantErr {
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
}
if err == nil && opts.Mode != tc.wantMode {
t.Errorf("mode=%q want %q", opts.Mode, tc.wantMode)
}
return nil
}
_ = cmd.Execute()
})
}
}
func TestFormatOptions_NDJSONSplitsList(t *testing.T) {
var buf bytes.Buffer
fopts := &FormatOptions{Mode: FormatNDJSON}
arr := []map[string]string{{"id": "a"}, {"id": "b"}}
if err := fopts.Emit(&buf, arr, nil); err != nil {
t.Fatalf("Emit: %v", err)
}
want := `{"id":"a"}` + "\n" + `{"id":"b"}` + "\n"
if buf.String() != want {
t.Errorf("got %q want %q", buf.String(), want)
}
}
func TestFormatOptions_JSONEmitsArray(t *testing.T) {
var buf bytes.Buffer
fopts := &FormatOptions{Mode: FormatJSON}
arr := []map[string]string{{"id": "a"}, {"id": "b"}}
if err := fopts.Emit(&buf, arr, nil); err != nil {
t.Fatalf("Emit: %v", err)
}
// v0.7: JSON path wraps in success envelope {ok:true, data:[...]}.
// Unmarshal the envelope and check data array length.
var env struct {
OK bool `json:"ok"`
Data []map[string]string `json:"data"`
}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("not valid envelope JSON: %v\n%s", err, buf.String())
}
if !env.OK {
t.Errorf("expected ok:true in envelope")
}
if len(env.Data) != 2 {
t.Errorf("got %d items, want 2", len(env.Data))
}
}
func TestFormatOptions_TextModeReturnsError(t *testing.T) {
fopts := &FormatOptions{Mode: FormatText}
err := fopts.Emit(&bytes.Buffer{}, map[string]string{"a": "b"}, nil)
if err == nil {
t.Error("expected error for text mode, got nil")
}
}
// TestResolveDefault_AlwaysJSON verifies v0.7 semantics: default is FormatJSON
// regardless of whether stdout is a TTY (BREAKING change from v0.6).
// TestResolveDefault_AlwaysJSON pins the deliberate JSON-always default (no
// TTY switch to text): the default output never depends on whether stdout is a
// TTY, so agents get predictable JSON. Humans opt into text with --format text.
func TestResolveDefault_AlwaysJSON(t *testing.T) {
for _, isTTY := range []bool{true, false} {
o := &FormatOptions{}
o.ResolveDefault(isTTY)
if o.Mode != FormatJSON {
t.Errorf("isTTY=%v: expected default FormatJSON, got %v", isTTY, o.Mode)
}
if o.TTY != isTTY {
t.Errorf("isTTY=%v: TTY field not propagated", isTTY)
}
}
}
func TestResolveDefault(t *testing.T) {
cases := []struct {
name string
mode FormatMode // pre-set Mode
jq string
isTTY bool
wantMode FormatMode
}{
// JSON-always default regardless of TTY (predictable for agents).
{"empty isTTY", "", "", true, FormatJSON},
{"empty no-tty", "", "", false, FormatJSON},
{"already set keeps value tty", FormatNDJSON, "", true, FormatNDJSON},
{"already set keeps value no-tty", FormatJSON, "", false, FormatJSON},
// --jq with unset --format promotes to JSON regardless of TTY so the
// filter has somewhere to apply (silent text drop would surprise users).
{"jq forces json on TTY", "", ".[]", true, FormatJSON},
{"jq with explicit ndjson preserved", FormatNDJSON, ".[]", true, FormatNDJSON},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
o := &FormatOptions{Mode: tc.mode, JQ: tc.jq}
o.ResolveDefault(tc.isTTY)
if o.Mode != tc.wantMode {
t.Errorf("mode=%q want %q", o.Mode, tc.wantMode)
}
})
}
}
// TestCheckFormatFlag_InvalidExitTwo guards the contract that flag-value
// validation maps to exit 2 (FlagError class), not the unclassified bucket.
func TestCheckFormatFlag_InvalidExitTwo(t *testing.T) {
cases := []struct {
name string
args []string
}{
{"invalid format value", []string{"--format", "yaml"}},
{"human rejected as invalid", []string{"--format", "human"}},
{"jq with explicit text mode", []string{"--format", "text", "--jq", ".id"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{}
// --format / --jq are persistent root flags in production; register
// them on this bare cmd so the test can exercise CheckFormatFlag in
// isolation.
cmd.PersistentFlags().String("format", "", "")
cmd.PersistentFlags().String("jq", "", "")
cmd.SetArgs(tc.args)
var got error
cmd.RunE = func(c *cobra.Command, _ []string) error {
_, err := CheckFormatFlag(c)
got = err
return err
}
_ = cmd.Execute()
if got == nil {
t.Fatal("expected error, got nil")
}
var fe *FlagError
if !errors.As(got, &fe) {
t.Fatalf("error %v is not a *FlagError; would map to exit 1 instead of 2", got)
}
if ExitCode(got) != 2 {
t.Errorf("ExitCode=%d, want 2", ExitCode(got))
}
})
}
}
func TestCheckFormatFlag_InvalidValueRejected(t *testing.T) {
for _, v := range []string{"human", "yaml", "xml", "table"} {
v := v
t.Run(v, func(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("format", "", "")
_ = cmd.Flags().Set("format", v)
_, err := CheckFormatFlag(cmd)
if err == nil {
t.Fatalf("expected error for --format %q", v)
}
if !strings.Contains(err.Error(), "text | json | ndjson") {
t.Errorf("expected enum hint for --format %q; got %v", v, err)
}
})
}
}
func TestCheckFormatFlag_TextAccepted(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("format", "", "")
_ = cmd.Flags().Set("format", "text")
o, err := CheckFormatFlag(cmd)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if o.Mode != FormatText {
t.Errorf("got %v, want FormatText", o.Mode)
}
}
func TestFromEnv_AppliesWEKNORA_FORMAT(t *testing.T) {
t.Setenv("WEKNORA_FORMAT", "ndjson")
o := &FormatOptions{}
o.FromEnv()
if o.Mode != FormatNDJSON {
t.Errorf("expected FormatNDJSON from env; got %v", o.Mode)
}
}
func TestFromEnv_PrecedenceFlagOverridesEnv(t *testing.T) {
t.Setenv("WEKNORA_FORMAT", "ndjson")
o := &FormatOptions{Mode: FormatJSON}
o.FromEnv()
if o.Mode != FormatJSON {
t.Errorf("flag should win over env; got %v", o.Mode)
}
}
func TestFromEnv_InvalidValueIgnored(t *testing.T) {
t.Setenv("WEKNORA_FORMAT", "yaml")
o := &FormatOptions{}
o.FromEnv()
if o.Mode != "" {
t.Errorf("invalid env should be ignored; got %v", o.Mode)
}
}
// TestResolveDefault_AppliesEnv pins that ResolveDefault honours
// WEKNORA_FORMAT. Regression: commands call CheckFormatFlag + ResolveDefault
// but (nearly) none called FromEnv, so the documented env var was silently
// ignored on success output across the whole CLI until ResolveDefault folded
// it in. This is the path every command's RunE actually takes.
func TestResolveDefault_AppliesEnv(t *testing.T) {
t.Setenv("WEKNORA_FORMAT", "text")
o := &FormatOptions{} // no --format → Mode empty, as after CheckFormatFlag
o.ResolveDefault(false)
if o.Mode != FormatText {
t.Errorf("WEKNORA_FORMAT=text must drive ResolveDefault to text; got %v", o.Mode)
}
}
// TestResolveDefault_FlagBeatsEnv pins precedence: an explicit --format
// (Mode already set) wins over WEKNORA_FORMAT.
func TestResolveDefault_FlagBeatsEnv(t *testing.T) {
t.Setenv("WEKNORA_FORMAT", "text")
o := &FormatOptions{Mode: FormatJSON} // --format json supplied
o.ResolveDefault(false)
if o.Mode != FormatJSON {
t.Errorf("explicit --format json must beat WEKNORA_FORMAT=text; got %v", o.Mode)
}
}
// TestResolveDefault_NoEnvNoFlag_UsesDefault pins the fallback.
func TestResolveDefault_NoEnvNoFlag_UsesDefault(t *testing.T) {
t.Setenv("WEKNORA_FORMAT", "")
o := &FormatOptions{}
o.ResolveDefault(false)
if o.Mode != DefaultFormatMode {
t.Errorf("no flag + no env must fall back to default %v; got %v", DefaultFormatMode, o.Mode)
}
}
+51
View File
@@ -0,0 +1,51 @@
package cmdutil
import (
"fmt"
"net/url"
"strings"
)
// NormalizeHost validates and canonicalizes a `--host` value. Trailing
// slashes are trimmed. Every failure path returns CodeInputInvalidArgument -
// a present-but-empty flag is treated as a bad value, not as a missing flag
// (cobra's required-flag layer is what catches the absent case).
//
// Rules:
// - non-empty
// - scheme is http or https
// - URL parses
// - u.Host is non-empty (rejects "http://" which url.Parse accepts)
func NormalizeHost(host string) (string, error) {
host = strings.TrimRight(strings.TrimSpace(host), "/")
if host == "" {
return "", NewError(CodeInputInvalidArgument, "--host must not be empty")
}
if err := ValidateHTTPURL("--host", host); err != nil {
return "", err
}
return host, nil
}
// ValidateHTTPURL applies the same http/https URL rules NormalizeHost uses,
// but parameterized by flagLabel so error messages name the caller's flag
// (e.g. "--host", "--from-url"). Returns CodeInputInvalidArgument on any
// failure. Empty `value` is not pre-checked here - callers that distinguish
// "missing" from "malformed" should test for empty before calling.
func ValidateHTTPURL(flagLabel, value string) error {
u, err := url.Parse(value)
if err != nil {
return NewError(CodeInputInvalidArgument, fmt.Sprintf("%s %q is not a valid URL: %v", flagLabel, value, err))
}
if u.Scheme != "http" && u.Scheme != "https" {
return &Error{
Code: CodeInputInvalidArgument,
Message: fmt.Sprintf("%s scheme must be http or https, got %q", flagLabel, u.Scheme),
Hint: "example: https://kb.example.com",
}
}
if u.Host == "" {
return NewError(CodeInputInvalidArgument, fmt.Sprintf("%s %q is missing the host portion", flagLabel, value))
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
package cmdutil
import (
"fmt"
"io"
"os"
"strings"
"time"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// rfc3339Example is the canonical hint shown when a time-flag value isn't RFC3339.
const rfc3339Example = "2006-01-02T15:04:05Z"
// ParseTimeFlag parses an RFC3339 time-flag value. An empty value is treated as
// unset and returns (nil, nil) — callers use nil to mean "no bound". A
// malformed value returns a CodeInputInvalidArgument error naming the label;
// on success it returns the parsed time. label is used verbatim in the message
// (e.g. "--start-time" for a CLI flag, "start_time" for an MCP tool field).
//
// One place owns the RFC3339 time-flag shape — the parse, error code, and
// wording consistency sibling of ValidateEnum.
func ParseTimeFlag(label, value string) (*time.Time, error) {
if value == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, &Error{
Code: CodeInputInvalidArgument,
Message: fmt.Sprintf("%s must be RFC3339 (e.g. %s), got %q",
label, rfc3339Example, value),
}
}
return &t, nil
}
// ValidateEnum checks a closed-set flag value, case-insensitively. An empty
// value is treated as unset and returns "" with no error (callers use the empty
// case to mean "no filter" / "server default"). A non-empty value that matches
// no allowed entry returns CodeInputInvalidArgument naming the flag and the
// valid set; on a match it returns the canonical spelling from allowed (so the
// server, which may be case-sensitive, receives a normalized value).
//
// This is the one place the "validate a flag against a closed enum" shape
// lives — keeping the membership test, error code, and message wording
// consistent across every such flag instead of hand-rolling each.
func ValidateEnum(flagName, value string, allowed []string) (string, error) {
if value == "" {
return "", nil
}
for _, a := range allowed {
if strings.EqualFold(value, a) {
return a, nil
}
}
return "", &Error{
Code: CodeInputInvalidArgument,
Message: fmt.Sprintf("--%s must be one of: %s - got %q",
flagName, strings.Join(allowed, " | "), value),
}
}
// EnumStrings converts a slice of a ~string enum type (e.g. the SDK's
// AllModelTypes / AllMessageSearchModes enumerators) to plain strings for
// ValidateEnum and flag-help wording. Sourcing the closed set from the SDK
// this way keeps the CLI from drifting from the server vocabulary.
func EnumStrings[T ~string](xs []T) []string {
out := make([]string, len(xs))
for i, x := range xs {
out[i] = string(x)
}
return out
}
// OpenInput returns a reader for path. If path == "-", returns stdin
// (iostreams.IO.In). Otherwise opens the file. Caller is responsible
// for closing if needed — for typical "-input <file>"/"--input -" CLI
// patterns the file is fully read before the command exits and OS
// reclaims the FD, so closing is cosmetic.
func OpenInput(path string) (io.Reader, error) {
if path == "-" {
return iostreams.IO.In, nil
}
return os.Open(path)
}
+66
View File
@@ -0,0 +1,66 @@
package cmdutil
import (
"errors"
"strings"
"testing"
"time"
)
func TestParseTimeFlag(t *testing.T) {
// Empty → unset (nil, nil).
if got, err := ParseTimeFlag("--before", ""); err != nil || got != nil {
t.Errorf(`empty: got (%v, %v), want (nil, nil)`, got, err)
}
// Valid RFC3339 → parsed time.
want, _ := time.Parse(time.RFC3339, "2026-06-01T00:00:00Z")
got, err := ParseTimeFlag("--before", "2026-06-01T00:00:00Z")
if err != nil || got == nil || !got.Equal(want) {
t.Errorf("valid: got (%v, %v)", got, err)
}
// Malformed → CodeInputInvalidArgument naming the label.
_, err = ParseTimeFlag("start_time", "2026-06-01")
var typed *Error
if !errors.As(err, &typed) || typed.Code != CodeInputInvalidArgument {
t.Fatalf("malformed: expected CodeInputInvalidArgument, got %v", err)
}
if !strings.Contains(typed.Message, "start_time") || !strings.Contains(typed.Message, "RFC3339") {
t.Errorf("message %q missing label/RFC3339 hint", typed.Message)
}
}
func TestValidateEnum(t *testing.T) {
allowed := []string{"keyword", "vector", "hybrid"}
// Empty value is "unset" — allowed, returns "".
if got, err := ValidateEnum("mode", "", allowed); err != nil || got != "" {
t.Errorf(`empty: got (%q, %v), want ("", nil)`, got, err)
}
// Exact and case-insensitive matches return the canonical spelling.
for _, in := range []string{"hybrid", "Hybrid", "HYBRID"} {
got, err := ValidateEnum("mode", in, allowed)
if err != nil || got != "hybrid" {
t.Errorf(`%q: got (%q, %v), want ("hybrid", nil)`, in, got, err)
}
}
// Canonical spelling comes from `allowed`, not the input casing.
if got, _ := ValidateEnum("type", "embedding", []string{"Embedding", "Rerank"}); got != "Embedding" {
t.Errorf(`canonical: got %q, want "Embedding"`, got)
}
// Unknown value → CodeInputInvalidArgument naming the flag + valid set.
_, err := ValidateEnum("mode", "hybird", allowed)
var typed *Error
if !errors.As(err, &typed) || typed.Code != CodeInputInvalidArgument {
t.Fatalf("unknown: expected CodeInputInvalidArgument, got %v", err)
}
for _, want := range []string{"--mode", "keyword | vector | hybrid", `"hybird"`} {
if !strings.Contains(typed.Message, want) {
t.Errorf("message %q missing %q", typed.Message, want)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package cmdutil
import (
"context"
"fmt"
"regexp"
sdk "github.com/Tencent/WeKnora/client"
)
// uuidPattern matches the canonical 8-4-4-4-12 UUID form. WeKnora's KB ids
// are uuid.New().String() output stored as varchar(36); names are arbitrary
// user-supplied strings, so format-detection is unambiguous.
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// IsKBID reports whether s looks like a KB id. Used by Factory.ResolveKB and
// any caller that accepts a single id-or-name selector value (id vs name
// auto-detection).
func IsKBID(s string) bool { return uuidPattern.MatchString(s) }
// KBLister is the narrow SDK surface ResolveKBNameToID depends on. The
// production *sdk.Client satisfies it; tests inject fakes without standing
// up an HTTP server.
type KBLister interface {
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
}
// ResolveKBFlag interprets a raw --kb value (id or name) and returns the
// canonical id. Pass-through when raw already looks like an id; otherwise
// list and match by name. Shared by every command that takes a --kb flag
// directly (search chunks/docs, doc download, link …) so the id-or-name
// policy never drifts.
func ResolveKBFlag(ctx context.Context, lister KBLister, raw string) (string, error) {
if IsKBID(raw) {
return raw, nil
}
return ResolveKBNameToID(ctx, lister, raw)
}
// ResolveKBNameToID looks up a knowledge base by name and returns its ID.
// Used by `link` and `Factory.ResolveKB` - a single lookup so the match
// policy (currently exact case-sensitive) lives in one place.
func ResolveKBNameToID(ctx context.Context, lister KBLister, name string) (string, error) {
kbs, err := lister.ListKnowledgeBases(ctx)
if err != nil {
return "", WrapHTTP(err, "list knowledge bases")
}
for _, kb := range kbs {
if kb.Name == name {
return kb.ID, nil
}
}
return "", NewError(CodeKBNotFound, fmt.Sprintf("knowledge base not found: %s", name))
}
+61
View File
@@ -0,0 +1,61 @@
package cmdutil
import (
"io"
"os"
"github.com/spf13/cobra"
)
// validLogLevels is the canonical set of accepted log levels
// (debug|info|warn|error). Map gives O(1) validation.
var validLogLevels = map[string]bool{
"error": true,
"warn": true,
"info": true,
"debug": true,
}
// IsValidLogLevel reports whether v is one of the accepted log levels.
// Exported so factory-level validation can give a strict error on an
// explicit --log-level invalid value (env values stay silent-fallthrough).
func IsValidLogLevel(v string) bool { return validLogLevels[v] }
// LogLevelDefault is the resolved level when nothing is configured: only
// emit error-level logs (minimal noise for non-debug usage).
const LogLevelDefault = "error"
// AddLogLevelFlag registers --log-level as a persistent flag on cmd
// (typically the root command). Unlike --format (per-command, Method D),
// --log-level is uniformly applied across all SDK calls so it lives on root.
func AddLogLevelFlag(cmd *cobra.Command) {
cmd.PersistentFlags().String("log-level", "", "Log verbosity: error | warn | info | debug (env: WEKNORA_LOG_LEVEL)")
}
// ResolveLogLevel resolves the effective log level using priority (high → low):
// 1. --log-level flag (if explicitly set, even from a parent command's persistent flagset)
// 2. WEKNORA_LOG_LEVEL env (if set to a valid value)
// 3. Default "error"
//
// Invalid env values fall through to the next priority (no error — env is
// best-effort). The stderr writer is retained in the signature for symmetry
// with ApplyLogLevel callers but is unused at the OS level.
func ResolveLogLevel(cmd *cobra.Command, _ io.Writer) (string, bool) {
// Priority 1: explicit --log-level flag.
if cmd != nil {
if f := cmd.Flags().Lookup("log-level"); f != nil && f.Changed {
v := f.Value.String()
if validLogLevels[v] {
return v, false
}
}
}
// Priority 2: WEKNORA_LOG_LEVEL env (silently skip invalid values).
if v := os.Getenv("WEKNORA_LOG_LEVEL"); v != "" && validLogLevels[v] {
return v, false
}
// Priority 3: default.
return LogLevelDefault, false
}
+53
View File
@@ -0,0 +1,53 @@
package cmdutil
import (
"bytes"
"testing"
"github.com/spf13/cobra"
)
func TestResolveLogLevel(t *testing.T) {
cases := []struct {
name string
flagVal string // "" means flag not set
envVal string // WEKNORA_LOG_LEVEL
wantLvl string
wantWarn bool
}{
{"default", "", "", "error", false},
{"flag wins over env", "debug", "info", "debug", false},
{"env when no flag", "", "info", "info", false},
{"invalid env falls through to default", "", "trace", "error", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("WEKNORA_LOG_LEVEL", tc.envVal)
cmd := &cobra.Command{}
AddLogLevelFlag(cmd)
if tc.flagVal != "" {
if err := cmd.ParseFlags([]string{"--log-level", tc.flagVal}); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
}
var stderr bytes.Buffer
got, warn := ResolveLogLevel(cmd, &stderr)
if got != tc.wantLvl {
t.Errorf("level=%q want %q", got, tc.wantLvl)
}
if warn != tc.wantWarn {
t.Errorf("warn=%v want %v", warn, tc.wantWarn)
}
})
}
}
func TestAddLogLevelFlag_RegistersPersistent(t *testing.T) {
cmd := &cobra.Command{}
AddLogLevelFlag(cmd)
if f := cmd.PersistentFlags().Lookup("log-level"); f == nil {
t.Error("--log-level must be registered as a persistent flag (global across all subcommands)")
}
}
+53
View File
@@ -0,0 +1,53 @@
package cmdutil
import (
"context"
"fmt"
sdk "github.com/Tencent/WeKnora/client"
)
// ModelLister is the narrow SDK surface ResolveModelRef needs. *sdk.Client
// satisfies it.
type ModelLister interface {
ListModels(ctx context.Context) ([]sdk.Model, error)
}
// ResolveModelRef returns ref unchanged when it is already a model id (UUID);
// otherwise it resolves ref as a model NAME among models of wantType (pass ""
// to match any type) and returns that model's id. Mirrors the id-or-name policy
// of --kb so model-referencing flags accept either.
//
// Errors with resource.not_found when nothing matches, and
// input.invalid_argument when the name is ambiguous (so the caller passes an id
// instead). The empty ref is returned as-is for callers to treat as "unset".
func ResolveModelRef(ctx context.Context, lister ModelLister, ref, wantType string) (string, error) {
if ref == "" || IsKBID(ref) { // IsKBID is a generic UUID check
return ref, nil
}
models, err := lister.ListModels(ctx)
if err != nil {
return "", WrapHTTP(err, "list models to resolve %q", ref)
}
var matches []sdk.Model
for _, m := range models {
if m.Name == ref && (wantType == "" || string(m.Type) == wantType) {
matches = append(matches, m)
}
}
switch len(matches) {
case 1:
return matches[0].ID, nil
case 0:
msg := fmt.Sprintf("no model named %q", ref)
if wantType != "" {
msg = fmt.Sprintf("no %s model named %q", wantType, ref)
}
return "", NewError(CodeResourceNotFound, msg).
WithHint("discover models with `weknora model list`")
default:
return "", NewError(CodeInputInvalidArgument,
fmt.Sprintf("%q matches %d models — pass the model id instead", ref, len(matches))).
WithHint("`weknora model list` shows the ids")
}
}
+62
View File
@@ -0,0 +1,62 @@
package cmdutil
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeModelLister struct {
models []sdk.Model
err error
}
func (f *fakeModelLister) ListModels(_ context.Context) ([]sdk.Model, error) {
return f.models, f.err
}
func TestResolveModelRef(t *testing.T) {
lister := &fakeModelLister{models: []sdk.Model{
{ID: "11111111-1111-1111-1111-111111111111", Name: "qwen2", Type: sdk.ModelTypeKnowledgeQA},
{ID: "22222222-2222-2222-2222-222222222222", Name: "nomic", Type: sdk.ModelTypeEmbedding},
{ID: "33333333-3333-3333-3333-333333333333", Name: "dup", Type: sdk.ModelTypeEmbedding},
{ID: "44444444-4444-4444-4444-444444444444", Name: "dup", Type: sdk.ModelTypeKnowledgeQA},
}}
// A UUID passes through untouched without hitting the lister.
got, err := ResolveModelRef(context.Background(), &fakeModelLister{err: errors.New("must not call")},
"99999999-9999-9999-9999-999999999999", "Embedding")
require.NoError(t, err)
assert.Equal(t, "99999999-9999-9999-9999-999999999999", got)
// Empty passes through (caller treats as unset).
got, err = ResolveModelRef(context.Background(), lister, "", "")
require.NoError(t, err)
assert.Equal(t, "", got)
// Name + type → id.
got, err = ResolveModelRef(context.Background(), lister, "qwen2", "KnowledgeQA")
require.NoError(t, err)
assert.Equal(t, "11111111-1111-1111-1111-111111111111", got)
// Type filter disambiguates same-named models across types.
got, err = ResolveModelRef(context.Background(), lister, "dup", "Embedding")
require.NoError(t, err)
assert.Equal(t, "33333333-3333-3333-3333-333333333333", got)
// No match → resource.not_found.
_, err = ResolveModelRef(context.Background(), lister, "missing", "Embedding")
var ce *Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, CodeResourceNotFound, ce.Code)
// Ambiguous (same name, no type filter) → input.invalid_argument.
_, err = ResolveModelRef(context.Background(), lister, "dup", "")
require.ErrorAs(t, err, &ce)
assert.Equal(t, CodeInputInvalidArgument, ce.Code)
}
+48
View File
@@ -0,0 +1,48 @@
package cmdutil
import (
"fmt"
"strings"
)
// ValidateProfileName enforces the profile-name allowlist: letters, digits,
// dash, underscore, dot. The `.` exception lets email / DNS-like names
// through; `.` / `..` and path separators are structurally rejected so a
// hand-edited config.yaml can't claim a profile whose name walks out of the
// keyring namespace.
//
// Crucially this also rejects shell metacharacters (space, `;`, `&`, `|`,
// `$`, quotes, backticks, etc.), so the profile name remains safe to embed
// in envelope.error.retry_argv — even an agent that joins the argv and exec's
// `sh -c <joined>` cannot be tricked via a maliciously-named profile.
func ValidateProfileName(name string) error {
if name == "" {
return &Error{
Code: CodeInputInvalidArgument,
Message: "profile name must not be empty",
}
}
if name == "." || name == ".." || strings.Contains(name, "/") || strings.Contains(name, "\\") {
return &Error{
Code: CodeInputInvalidArgument,
Message: fmt.Sprintf("profile name %q is reserved or path-like", name),
Hint: "use letters, digits, dashes, underscores, or dots",
}
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-' || r == '_' || r == '.':
continue
default:
return &Error{
Code: CodeInputInvalidArgument,
Message: fmt.Sprintf("profile name %q contains invalid character %q", name, r),
Hint: "use letters, digits, dashes, underscores, or dots",
}
}
}
return nil
}
+113
View File
@@ -0,0 +1,113 @@
package cmdutil
import (
"errors"
"strings"
"testing"
)
// TestValidateProfileName_AcceptsAllowlist verifies the documented charset
// is accepted as-is.
func TestValidateProfileName_AcceptsAllowlist(t *testing.T) {
for _, name := range []string{
"default",
"prod",
"staging-2026",
"ci_runner",
"alice.example",
"a", // single char
"A-Z_0-9",
} {
if err := ValidateProfileName(name); err != nil {
t.Errorf("ValidateProfileName(%q) unexpected error: %v", name, err)
}
}
}
// TestValidateProfileName_RejectsEmpty guards the empty-string base case.
func TestValidateProfileName_RejectsEmpty(t *testing.T) {
err := ValidateProfileName("")
if err == nil {
t.Fatal("expected error for empty name")
}
var ce *Error
if !errors.As(err, &ce) || ce.Code != CodeInputInvalidArgument {
t.Errorf("expected input.invalid_argument, got %v", err)
}
}
// TestValidateProfileName_RejectsShellMetachars is the security-critical
// case: anything that could break retry_argv shell interpolation must be
// rejected at the entry point. If this test ever loosens, an agent that
// joins-and-exec()s retry_argv becomes injectable via a malicious profile name.
func TestValidateProfileName_RejectsShellMetachars(t *testing.T) {
cases := []string{
"evil; rm -rf /",
"foo && bar",
"foo || bar",
"foo|bar",
"foo`whoami`",
"foo$(whoami)",
"foo$bar",
"foo>out",
"foo<in",
"foo bar", // space alone
"foo'bar",
`foo"bar`,
"foo\nbar", // newline
"foo\tbar", // tab
"foo\\bar", // backslash (also path-like)
"foo/bar", // slash (also path-like)
"#foo", // comment marker
"foo*bar", // glob
"foo?bar", // glob
"foo~bar", // home expansion
"foo!bar", // history expansion
"foo,bar", // brace-expansion-ish
"foo[bar",
}
for _, name := range cases {
err := ValidateProfileName(name)
if err == nil {
t.Errorf("ValidateProfileName(%q) should have rejected the name; a name echoed into retry_argv / prose would be injectable", name)
continue
}
var ce *Error
if !errors.As(err, &ce) || ce.Code != CodeInputInvalidArgument {
t.Errorf("ValidateProfileName(%q) returned wrong code %v; want input.invalid_argument", name, err)
}
}
}
// TestValidateProfileName_RejectsPathTraversal covers the keyring-namespace
// escape vector. `.` and `..` are reserved, and any slash is rejected.
func TestValidateProfileName_RejectsPathTraversal(t *testing.T) {
for _, name := range []string{".", "..", "../foo", "foo/..", "a/b", `a\b`} {
err := ValidateProfileName(name)
if err == nil {
t.Errorf("ValidateProfileName(%q) should have rejected the path-like name", name)
continue
}
// Path-shaped names hit the dedicated "reserved or path-like" branch
// first (clearer hint than "invalid character %q") for `..` and the
// slashed forms.
if name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
if !strings.Contains(err.Error(), "reserved or path-like") {
t.Errorf("ValidateProfileName(%q): expected path-like hint, got %v", name, err)
}
}
}
}
// TestValidateProfileName_HintMentionsAllowlist verifies the user-facing
// hint actually tells them what's allowed (not just "invalid").
func TestValidateProfileName_HintMentionsAllowlist(t *testing.T) {
err := ValidateProfileName("foo bar")
var ce *Error
if !errors.As(err, &ce) {
t.Fatalf("expected typed Error, got %v", err)
}
if !strings.Contains(ce.Hint, "letters") || !strings.Contains(ce.Hint, "dots") {
t.Errorf("hint should describe allowlist; got %q", ce.Hint)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package-level note:
//
// SetRisk / SetWriteRisk attach risk metadata to a cobra command via cobra
// annotations. The SetAgentHelp wrapper in agenthelp.go reads these
// annotations and prepends a "Risk: <action> (<level>)" line at the top
// of human help output. Destructive ops use SetRisk ("destructive");
// reversible update commands use SetWriteRisk ("write"). "read" is reserved.
//
// envelope.error.risk.action is emitted separately by the
// ConfirmDestructive / ConfirmWrite callsite argument and does NOT read
// these annotations.
package cmdutil
import "github.com/spf13/cobra"
// Risk levels emitted in the annotation / envelope:
// - RiskDestructive: irreversible ops (delete).
// - RiskWrite: reversible metadata edits (kb / agent / doc update).
//
// "read" remains reserved (read-only commands carry no risk annotation).
const (
RiskDestructive = "destructive"
RiskWrite = "write"
)
// SetRisk writes destructive risk metadata to a cobra command's annotations.
// Idempotent: re-calling with the same action overwrites cleanly.
//
// nil-map guard: cobra.Command.Annotations is `map[string]string` and
// defaults to nil. Writing to a nil map panics, so we allocate first.
func SetRisk(cmd *cobra.Command, action string) {
setRisk(cmd, RiskDestructive, action)
}
// SetWriteRisk mirrors SetRisk but tags the command at the "write" level —
// used by update commands (kb / agent / doc update), which are reversible
// metadata edits rather than irreversible destructive ops. They remain
// confirmation-gated; only the level label differs.
func SetWriteRisk(cmd *cobra.Command, action string) {
setRisk(cmd, RiskWrite, action)
}
// setRisk is the shared writer behind SetRisk / SetWriteRisk.
func setRisk(cmd *cobra.Command, level, action string) {
if cmd.Annotations == nil {
cmd.Annotations = make(map[string]string)
}
cmd.Annotations["risk.level"] = level
cmd.Annotations["risk.action"] = action
}
// GetRisk reads risk metadata. Returns (level, action, ok). ok=false when
// either the annotations map is nil or risk.action key is missing.
//
// Reading from a nil map is safe in Go (returns zero value); we still
// check map-existence explicitly for clarity.
func GetRisk(cmd *cobra.Command) (level, action string, ok bool) {
if cmd.Annotations == nil {
return "", "", false
}
action, ok = cmd.Annotations["risk.action"]
if !ok {
return "", "", false
}
level = cmd.Annotations["risk.level"]
return level, action, true
}
+75
View File
@@ -0,0 +1,75 @@
package cmdutil
import (
"bytes"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
func TestSetAgentHelp_PrependsRiskWhenAnnotated(t *testing.T) {
cmd := &cobra.Command{
Use: "delete",
Short: "Delete a knowledge base.",
Long: "Delete a knowledge base.\n\nThis is irreversible.",
}
SetRisk(cmd, "kb.delete")
SetAgentHelp(cmd, AgentHelp{
UsedFor: "remove a KB and all its docs/chunks",
Warnings: []string{"never auto -y"},
})
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
t.Setenv("WEKNORA_AGENT_HELP", "")
cmd.HelpFunc()(cmd, []string{})
out := buf.String()
assert.True(t, strings.HasPrefix(out, "Risk: kb.delete (destructive)\n\n"),
"expected Risk: line at top; got:\n%s", out)
assert.Contains(t, out, "AI agents:", "expected AI agents block still present")
assert.Contains(t, out, "- never auto -y", "expected Warnings preserved")
}
func TestSetAgentHelp_NoRiskWhenUnannotated(t *testing.T) {
cmd := &cobra.Command{
Use: "list",
Short: "List knowledge bases.",
Long: "List knowledge bases.",
}
// NO SetRisk call — cmd.Annotations stays nil
SetAgentHelp(cmd, AgentHelp{UsedFor: "list KBs"})
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
t.Setenv("WEKNORA_AGENT_HELP", "")
cmd.HelpFunc()(cmd, []string{})
out := buf.String()
assert.False(t, strings.HasPrefix(out, "Risk:"),
"expected NO Risk: line when not SetRisk'd; got:\n%s", out)
}
func TestSetAgentHelp_JSONPathUnaffectedByRisk(t *testing.T) {
cmd := &cobra.Command{Use: "delete"}
SetRisk(cmd, "kb.delete")
SetAgentHelp(cmd, AgentHelp{UsedFor: "x", Warnings: []string{"w1"}})
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
t.Setenv("WEKNORA_AGENT_HELP", "1")
cmd.HelpFunc()(cmd, []string{})
out := buf.String()
assert.False(t, strings.HasPrefix(out, "Risk:"),
"JSON path should NOT contain Risk: prefix; got:\n%s", out)
assert.Contains(t, out, `"used_for": "x"`, "expected JSON body emitted")
}
+54
View File
@@ -0,0 +1,54 @@
package cmdutil
import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
func TestSetRisk_WritesAnnotations(t *testing.T) {
cmd := &cobra.Command{Use: "delete"}
SetRisk(cmd, "kb.delete")
assert.Equal(t, "destructive", cmd.Annotations["risk.level"])
assert.Equal(t, "kb.delete", cmd.Annotations["risk.action"])
}
func TestSetRisk_NilMapGuard(t *testing.T) {
cmd := &cobra.Command{Use: "delete"}
// cmd.Annotations is nil by default
assert.Nil(t, cmd.Annotations)
SetRisk(cmd, "agent.delete")
// Should not panic; should initialize map
assert.NotNil(t, cmd.Annotations)
assert.Equal(t, "agent.delete", cmd.Annotations["risk.action"])
}
func TestGetRisk_ReturnsWritten(t *testing.T) {
cmd := &cobra.Command{Use: "delete"}
SetRisk(cmd, "doc.delete")
level, action, ok := GetRisk(cmd)
assert.True(t, ok)
assert.Equal(t, "destructive", level)
assert.Equal(t, "doc.delete", action)
}
func TestGetRisk_NotFound(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
level, action, ok := GetRisk(cmd)
assert.False(t, ok)
assert.Empty(t, level)
assert.Empty(t, action)
}
func TestGetRisk_NilAnnotationsMap(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
// Annotations is nil — should not panic, return ok=false
assert.Nil(t, cmd.Annotations)
_, _, ok := GetRisk(cmd)
assert.False(t, ok)
}
func TestRiskDestructive_Const(t *testing.T) {
assert.Equal(t, "destructive", RiskDestructive)
}
+117
View File
@@ -0,0 +1,117 @@
package cmdutil
import "sort"
// levenshtein returns the rune-aware edit distance between a and b. Operates
// on runes (not bytes) so multi-byte names compare correctly.
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
la, lb := len(ra), len(rb)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
del := prev[j] + 1
ins := curr[j-1] + 1
sub := prev[j-1] + cost
m := del
if ins < m {
m = ins
}
if sub < m {
m = sub
}
curr[j] = m
}
prev, curr = curr, prev
}
return prev[lb]
}
// SuggestOne returns the single candidate closest to target by edit distance
// when it is a plausible typo (distance ≤ 2), or "" when none qualifies. Ties
// are broken lexicographically so the result is deterministic regardless of
// candidate ordering (Go randomizes map-range, so callers passing map keys
// would otherwise get flaky "did you mean" output). Use when a single
// "did you mean: X?" is wanted (e.g. profile lookup); SuggestClosest returns
// the ranked list instead.
func SuggestOne(target string, candidates []string) string {
if target == "" {
return ""
}
sorted := append([]string(nil), candidates...)
sort.Strings(sorted)
best := ""
bestD := 3
for _, c := range sorted {
if d := levenshtein(target, c); d < bestD {
bestD = d
best = c
}
}
if bestD > 2 {
return ""
}
return best
}
// SuggestClosest returns the candidates nearest to target by edit distance,
// closest first, limited to genuinely-plausible typos. Used to turn an
// "unknown subcommand"/"unknown name" error into an actionable "did you
// mean: X?" instead of dumping the whole list. Returns nil when nothing is
// close enough.
//
// Threshold scales with the target length (max(2, len/3)) so short names
// need an exact-ish match while longer names tolerate more slips. At most 3
// suggestions are returned.
func SuggestClosest(target string, candidates []string) []string {
if target == "" {
return nil
}
threshold := len([]rune(target)) / 3
if threshold < 2 {
threshold = 2
}
type scored struct {
name string
dist int
}
var matches []scored
for _, c := range candidates {
d := levenshtein(target, c)
if d <= threshold {
matches = append(matches, scored{c, d})
}
}
if len(matches) == 0 {
return nil
}
sort.SliceStable(matches, func(i, j int) bool {
if matches[i].dist != matches[j].dist {
return matches[i].dist < matches[j].dist
}
return matches[i].name < matches[j].name
})
var out []string
for i, m := range matches {
if i >= 3 {
break
}
out = append(out, m.name)
}
return out
}
+59
View File
@@ -0,0 +1,59 @@
package cmdutil
import (
"reflect"
"testing"
)
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"abc", "", 3},
{"", "abc", 3},
{"list", "list", 0},
{"list", "listt", 1},
{"list", "lst", 1},
{"limit", "limti", 2},
{"café", "cafe", 1}, // rune-aware: é vs e is one edit
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestClosest(t *testing.T) {
subs := []string{"create", "delete", "edit", "list", "pin", "status", "unpin", "view"}
cases := []struct {
target string
want []string
}{
{"lst", []string{"list"}}, // 1 edit
{"listt", []string{"list"}}, // 1 edit
{"vieww", []string{"view"}}, // 1 edit
{"creat", []string{"create"}}, // 1 edit
{"xyzzy", nil}, // nothing close
{"", nil}, // empty
{"pinn", []string{"pin"}}, // 1 edit (pin), unpin is 2
}
for _, tc := range cases {
got := SuggestClosest(tc.target, subs)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("SuggestClosest(%q) = %v, want %v", tc.target, got, tc.want)
}
}
}
// TestSuggestClosest_RanksByDistance pins closest-first ordering when
// candidates are at different distances.
func TestSuggestClosest_RanksByDistance(t *testing.T) {
// "creat" → "create" is 1 edit (append e); "delete" is far. create first.
got := SuggestClosest("creat", []string{"delete", "create"})
if len(got) == 0 || got[0] != "create" {
t.Errorf("closest to 'creat' should be 'create' first; got %v", got)
}
}