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
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 - "))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user