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,16 @@
|
||||
// Package build holds compile-time-injected build metadata.
|
||||
//
|
||||
// Values are set via -ldflags="-X github.com/Tencent/WeKnora/cli/internal/build.Version=..." at link time;
|
||||
// the Makefile's `version` target wires them up.
|
||||
package build
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
Date = "unknown"
|
||||
)
|
||||
|
||||
// Info returns a snapshot of the build metadata.
|
||||
func Info() (version, commit, date string) {
|
||||
return Version, Commit, Date
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestInfo_DefaultDevValues(t *testing.T) {
|
||||
v, c, d := Info()
|
||||
// In a non-ldflags-injected build, these are the package defaults; tests
|
||||
// that vary them need to assign at run time, not via -ldflags (testing
|
||||
// here only that Info returns whatever is current).
|
||||
assert.NotEmpty(t, v)
|
||||
assert.NotEmpty(t, c)
|
||||
assert.NotEmpty(t, d)
|
||||
}
|
||||
|
||||
func TestInfo_ReflectsInjectedValues(t *testing.T) {
|
||||
old := Version
|
||||
t.Cleanup(func() { Version = old })
|
||||
Version = "v0.0.0-test"
|
||||
v, _, _ := Info()
|
||||
assert.Equal(t, "v0.0.0-test", v)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package compat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/xdg"
|
||||
)
|
||||
|
||||
const ttl = 24 * time.Hour
|
||||
|
||||
func cachePath() (string, error) {
|
||||
return xdg.Path("XDG_CACHE_HOME", ".cache", "server-info.yaml")
|
||||
}
|
||||
|
||||
// LoadCache reads the cached Info. Returns (info, fresh, err).
|
||||
//
|
||||
// info == nil when no cache exists (err == nil)
|
||||
// fresh == false when the cache is missing or TTL-expired
|
||||
func LoadCache() (*Info, bool, error) {
|
||||
p, err := cachePath()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("read cache: %w", err)
|
||||
}
|
||||
var info Info
|
||||
if err := yaml.Unmarshal(data, &info); err != nil {
|
||||
return nil, false, fmt.Errorf("parse cache: %w", err)
|
||||
}
|
||||
fresh := time.Since(info.ProbedAt) < ttl
|
||||
return &info, fresh, nil
|
||||
}
|
||||
|
||||
// SaveCache atomically writes Info to the cache file (mode 0600).
|
||||
func SaveCache(info *Info) error {
|
||||
p, err := cachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return xdg.WriteAtomicYAML(p, info)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package compat_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/compat"
|
||||
)
|
||||
|
||||
func TestCache_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CACHE_HOME", dir)
|
||||
|
||||
want := &compat.Info{ServerVersion: "1.2.3", ProbedAt: time.Now().UTC().Truncate(time.Second)}
|
||||
if err := compat.SaveCache(want); err != nil {
|
||||
t.Fatalf("SaveCache: %v", err)
|
||||
}
|
||||
|
||||
got, fresh, err := compat.LoadCache()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCache: %v", err)
|
||||
}
|
||||
if !fresh {
|
||||
t.Error("just-saved cache should be fresh")
|
||||
}
|
||||
if got == nil || got.ServerVersion != want.ServerVersion {
|
||||
t.Errorf("ServerVersion = %v, want %q", got, want.ServerVersion)
|
||||
}
|
||||
|
||||
// File should live at $XDG_CACHE_HOME/weknora/server-info.yaml
|
||||
p := filepath.Join(dir, "weknora", "server-info.yaml")
|
||||
if _, err := filepath.Abs(p); err != nil {
|
||||
t.Errorf("path resolution: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Stale(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CACHE_HOME", dir)
|
||||
|
||||
old := &compat.Info{ServerVersion: "1.2.3", ProbedAt: time.Now().Add(-25 * time.Hour)}
|
||||
if err := compat.SaveCache(old); err != nil {
|
||||
t.Fatalf("SaveCache: %v", err)
|
||||
}
|
||||
|
||||
_, fresh, err := compat.LoadCache()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCache: %v", err)
|
||||
}
|
||||
if fresh {
|
||||
t.Error("25h old cache should be stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Missing(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
info, fresh, err := compat.LoadCache()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCache should not error on missing: %v", err)
|
||||
}
|
||||
if fresh {
|
||||
t.Error("missing cache must not be fresh")
|
||||
}
|
||||
if info != nil {
|
||||
t.Errorf("missing cache info should be nil, got %v", info)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package compat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Level is the client-server version compat level.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
OK Level = iota // compatible
|
||||
SoftWarn // server outdated; some new features may be unavailable
|
||||
HardError // incompatible; CLI or server must be upgraded
|
||||
)
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case OK:
|
||||
return "ok"
|
||||
case SoftWarn:
|
||||
return "soft_warn"
|
||||
case HardError:
|
||||
return "hard_error"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Compat compares client/server versions. Cross-minor drift is allowed:
|
||||
//
|
||||
// same major, client minor <= server minor -> OK
|
||||
// same major, client minor > server minor -> SoftWarn (some new features may be unavailable)
|
||||
// different major -> HardError
|
||||
// unparseable string -> OK (fail-open, do not block)
|
||||
// "(unknown)" / "" -> OK (dev build / server field missing)
|
||||
func Compat(serverVer, cliVer string) (Level, string) {
|
||||
sMaj, sMin, ok := parseSemver(serverVer)
|
||||
if !ok {
|
||||
return OK, ""
|
||||
}
|
||||
cMaj, cMin, ok := parseSemver(cliVer)
|
||||
if !ok {
|
||||
return OK, ""
|
||||
}
|
||||
if sMaj != cMaj {
|
||||
return HardError, fmt.Sprintf("incompatible: client %s vs server %s - upgrade required", cliVer, serverVer)
|
||||
}
|
||||
if cMin > sMin {
|
||||
return SoftWarn, fmt.Sprintf("server is older (server %s, client %s); some new features may be unavailable", serverVer, cliVer)
|
||||
}
|
||||
return OK, ""
|
||||
}
|
||||
|
||||
// parseSemver extracts (major, minor) from "X.Y.Z" or "X.Y.Z-suffix".
|
||||
// Accepts the leading "v" common in `git describe` output and Tencent/WeKnora
|
||||
// tag conventions ("v0.1.0"), since both server `/system/info.version` and
|
||||
// the CLI's own ldflags-injected build.Version may carry it.
|
||||
// Returns ok=false when the string is unrecognizable (empty / "(unknown)" / non-numeric).
|
||||
func parseSemver(s string) (major, minor int, ok bool) {
|
||||
if s == "" || s == "(unknown)" {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Accept the "v" prefix; both `git describe` output and Tencent/WeKnora tags carry it.
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
// Strip prerelease/build metadata.
|
||||
if i := strings.IndexAny(s, "-+"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
parts := strings.SplitN(s, ".", 3)
|
||||
if len(parts) < 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
maj, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
min, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return maj, min, true
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package compat_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/compat"
|
||||
)
|
||||
|
||||
func TestCompat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverVer string
|
||||
cliVer string
|
||||
wantLevel compat.Level
|
||||
wantHintHas string
|
||||
}{
|
||||
{"equal", "1.2.3", "1.2.3", compat.OK, ""},
|
||||
{"client minor lower", "1.5.0", "1.2.0", compat.OK, ""},
|
||||
{"client minor higher", "1.2.0", "1.5.0", compat.SoftWarn, "server is older"},
|
||||
{"different major", "1.9.9", "2.0.0", compat.HardError, "incompatible"},
|
||||
{"client unset", "1.2.3", "(unknown)", compat.OK, ""}, // dev build; no warning
|
||||
{"server unset", "", "1.2.3", compat.OK, ""},
|
||||
{"malformed server", "garbage", "1.2.3", compat.OK, ""}, // fail-open
|
||||
|
||||
// "v" prefix tolerance (git describe + Tencent tag convention)
|
||||
{"v-prefix both", "v1.2.0", "v1.5.0", compat.SoftWarn, "server is older"},
|
||||
{"v-prefix server only", "v1.9.9", "2.0.0", compat.HardError, "incompatible"},
|
||||
{"v-prefix cli only", "1.9.9", "v2.0.0", compat.HardError, "incompatible"},
|
||||
|
||||
// Hint must be empty when level == OK (no false positives)
|
||||
{"OK hint empty", "1.2.3", "1.2.3", compat.OK, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
gotLevel, gotHint := compat.Compat(tt.serverVer, tt.cliVer)
|
||||
if gotLevel != tt.wantLevel {
|
||||
t.Errorf("%s: Level = %v, want %v", tt.name, gotLevel, tt.wantLevel)
|
||||
}
|
||||
if tt.wantHintHas != "" && !strings.Contains(gotHint, tt.wantHintHas) {
|
||||
t.Errorf("%s: hint = %q, should contain %q", tt.name, gotHint, tt.wantHintHas)
|
||||
}
|
||||
if tt.wantLevel == compat.OK && gotHint != "" {
|
||||
t.Errorf("%s: OK level should have empty hint, got %q", tt.name, gotHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package compat probes server `/system/info` and decides client-server
|
||||
// version skew level. Used by `weknora doctor`'s server_version check.
|
||||
//
|
||||
// 24h TTL state file cache, three-tier compat semantics
|
||||
// (major-mismatch=hard / minor-mismatch=soft / equal=ok).
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// Info is the cached server version snapshot.
|
||||
type Info struct {
|
||||
ServerVersion string `yaml:"server_version"`
|
||||
EngineInfo map[string]string `yaml:"engine_info,omitempty"`
|
||||
ProbedAt time.Time `yaml:"probed_at"`
|
||||
}
|
||||
|
||||
// ProbeService is the narrow SDK surface compat depends on; tests inject a fake.
|
||||
type ProbeService interface {
|
||||
GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error)
|
||||
}
|
||||
|
||||
// Probe calls server /system/info and returns a fresh Info snapshot.
|
||||
func Probe(ctx context.Context, c ProbeService) (*Info, error) {
|
||||
resp, err := c.GetSystemInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("probe server: %w", err)
|
||||
}
|
||||
return &Info{
|
||||
ServerVersion: resp.Version,
|
||||
ProbedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package compat_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/compat"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeProbeClient struct {
|
||||
info *sdk.SystemInfo
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeProbeClient) GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error) {
|
||||
return f.info, f.err
|
||||
}
|
||||
|
||||
func TestProbe_Success(t *testing.T) {
|
||||
c := &fakeProbeClient{info: &sdk.SystemInfo{Version: "1.2.3"}}
|
||||
got, err := compat.Probe(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
if got.ServerVersion != "1.2.3" {
|
||||
t.Errorf("ServerVersion = %q, want %q", got.ServerVersion, "1.2.3")
|
||||
}
|
||||
if got.ProbedAt.IsZero() {
|
||||
t.Error("ProbedAt should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbe_Error(t *testing.T) {
|
||||
c := &fakeProbeClient{err: errors.New("HTTP error 500: down")}
|
||||
_, err := compat.Probe(context.Background(), c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package config reads and writes the user-level config at
|
||||
// $XDG_CONFIG_HOME/weknora/config.yaml. yaml.v3 directly; viper is
|
||||
// intentionally not used. Multi-host profile map lives here;
|
||||
// the per-project link (.weknora/project.yaml) is handled by the
|
||||
// projectlink package.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/xdg"
|
||||
)
|
||||
|
||||
// Config is the on-disk schema. Empty zero-value is valid (returned when the
|
||||
// file does not exist) so commands like --help / version don't fail.
|
||||
type Config struct {
|
||||
CurrentProfile string `yaml:"current_profile,omitempty"`
|
||||
Profiles map[string]Profile `yaml:"profiles,omitempty"`
|
||||
|
||||
// Defaults holds CLI-wide defaults; fields opt-in.
|
||||
Defaults struct {
|
||||
Format string `yaml:"format,omitempty"`
|
||||
NoVersionCheck bool `yaml:"no_version_check,omitempty"`
|
||||
} `yaml:"defaults,omitempty"`
|
||||
}
|
||||
|
||||
// Profile is one named connection target (host + tenant + credential reference).
|
||||
type Profile struct {
|
||||
Host string `yaml:"host"`
|
||||
TenantID uint64 `yaml:"tenant_id,omitempty"`
|
||||
User string `yaml:"user,omitempty"`
|
||||
APIKeyRef string `yaml:"api_key_ref,omitempty"` // keychain://... or file://...
|
||||
TokenRef string `yaml:"token_ref,omitempty"` // keychain://... or file://...
|
||||
RefreshRef string `yaml:"refresh_token_ref,omitempty"`
|
||||
DefaultKBID string `yaml:"default_kb_id,omitempty"`
|
||||
}
|
||||
|
||||
// ErrCorrupt is returned by Load when the file exists but cannot be parsed.
|
||||
// Callers should map this to error code "local.config_corrupt".
|
||||
var ErrCorrupt = errors.New("config: file is malformed")
|
||||
|
||||
// Path returns the absolute config file path.
|
||||
// Honors XDG_CONFIG_HOME via internal/xdg.
|
||||
func Path() (string, error) {
|
||||
return xdg.Path("XDG_CONFIG_HOME", ".config", "config.yaml")
|
||||
}
|
||||
|
||||
// Load reads the config file. If it does not exist, returns a zero-value
|
||||
// Config with no error (commands like `version` and `--help` must not fail
|
||||
// just because the user has not run `auth login` yet).
|
||||
func Load() (*Config, error) {
|
||||
p, err := Path()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return &Config{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
var c Config
|
||||
if err := yaml.Unmarshal(data, &c); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrCorrupt, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Save writes the config atomically with mode 0600 via internal/xdg.WriteAtomicYAML.
|
||||
func Save(c *Config) error {
|
||||
p, err := Path()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return xdg.WriteAtomicYAML(p, c)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
)
|
||||
|
||||
func TestLoad_FileMissing(t *testing.T) {
|
||||
testutil.XDGTempDir(t)
|
||||
c, err := Load()
|
||||
require.NoError(t, err, "missing file must not error (commands like --help must work)")
|
||||
assert.NotNil(t, c)
|
||||
assert.Empty(t, c.Profiles)
|
||||
}
|
||||
|
||||
func TestSaveLoad_RoundTrip(t *testing.T) {
|
||||
testutil.XDGTempDir(t)
|
||||
in := &Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]Profile{
|
||||
"prod": {Host: "https://kb.example.com", TenantID: 7, APIKeyRef: "keychain://weknora/prod/access"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, Save(in))
|
||||
|
||||
out, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "prod", out.CurrentProfile)
|
||||
assert.Equal(t, "https://kb.example.com", out.Profiles["prod"].Host)
|
||||
assert.Equal(t, uint64(7), out.Profiles["prod"].TenantID)
|
||||
|
||||
p, err := Path()
|
||||
require.NoError(t, err)
|
||||
st, err := os.Stat(p)
|
||||
require.NoError(t, err)
|
||||
if runtime.GOOS != "windows" {
|
||||
// Windows file ACLs don't map to Unix mode bits; os.Chmod is
|
||||
// effectively a no-op for owner/group/other distinctions, so the
|
||||
// 0600 invariant only meaningfully holds on Unix-like platforms.
|
||||
assert.Equal(t, os.FileMode(0o600), st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Corrupt(t *testing.T) {
|
||||
dir := testutil.XDGTempDir(t)
|
||||
p := filepath.Join(dir, "weknora", "config.yaml")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o700))
|
||||
require.NoError(t, os.WriteFile(p, []byte("not: valid: yaml: ::"), 0o600))
|
||||
|
||||
_, err := Load()
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrCorrupt))
|
||||
}
|
||||
|
||||
func TestPath_HonorsXDG(t *testing.T) {
|
||||
xdg := filepath.Join(t.TempDir(), "xdg")
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
p, err := Path()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(xdg, "weknora", "config.yaml"), p)
|
||||
}
|
||||
|
||||
func TestPath_FallsBackToHome(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
t.Setenv("HOME", filepath.Join(t.TempDir(), "home"))
|
||||
p, err := Path()
|
||||
require.NoError(t, err)
|
||||
// Compare via slash-normalized form so the assertion is platform-agnostic
|
||||
// (Windows uses backslash; semantics here is "the relative tail").
|
||||
assert.Contains(t, filepath.ToSlash(p), "/.config/weknora/config.yaml")
|
||||
}
|
||||
|
||||
func TestSave_AtomicReplace(t *testing.T) {
|
||||
dir := testutil.XDGTempDir(t)
|
||||
require.NoError(t, Save(&Config{CurrentProfile: "a"}))
|
||||
require.NoError(t, Save(&Config{CurrentProfile: "b"}))
|
||||
out, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "b", out.CurrentProfile)
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "weknora", "*.tmp"))
|
||||
assert.Empty(t, matches)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package format
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
// WriteJSON serializes v as one-line JSON to w. Bare-data contract: list
|
||||
// commands emit their array directly, single-resource commands emit their
|
||||
// object directly. The shape is whatever the producing command marshals.
|
||||
func WriteJSON(w io.Writer, v any) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// WriteJSONFiltered serializes v to w with optional field restriction and
|
||||
// optional jq evaluation.
|
||||
//
|
||||
// - len(fields) == 0 → no field filter
|
||||
// - jqExpr == "" → no jq filter
|
||||
//
|
||||
// Field filter rules:
|
||||
//
|
||||
// - v marshals to a top-level array → each [*] object is restricted to
|
||||
// the named keys
|
||||
// - v marshals to a top-level object → the object is restricted to the
|
||||
// named keys
|
||||
// - v marshals to a scalar → unchanged
|
||||
//
|
||||
// Unknown field names are silently dropped so users can pass an aspirational
|
||||
// field set across heterogenous list outputs.
|
||||
func WriteJSONFiltered(w io.Writer, v any, fields []string, jqExpr string) error {
|
||||
if len(fields) == 0 && jqExpr == "" {
|
||||
return WriteJSON(w, v)
|
||||
}
|
||||
raw, err := marshalJSON(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
raw, err = applyBareFieldFilter(raw, fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if jqExpr != "" {
|
||||
return writeJQ(w, raw, jqExpr)
|
||||
}
|
||||
_, err = w.Write(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
// marshalJSON encodes v to a newline-terminated byte slice using the same
|
||||
// encoder settings as WriteJSON (HTML escaping disabled).
|
||||
func marshalJSON(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// applyBareFieldFilter dispatches on the JSON shape of raw and restricts
|
||||
// elements / object keys to the named fields.
|
||||
func applyBareFieldFilter(raw []byte, fields []string) ([]byte, error) {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 {
|
||||
return raw, nil
|
||||
}
|
||||
switch trimmed[0] {
|
||||
case '[':
|
||||
return filterArrayItems(raw, fields)
|
||||
case '{':
|
||||
return filterObjectKeys(raw, fields)
|
||||
default:
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package format_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
)
|
||||
|
||||
func TestWriteJSON_BareArray(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
if err := format.WriteJSON(buf, []map[string]string{
|
||||
{"id": "1", "name": "alpha"},
|
||||
{"id": "2", "name": "beta"},
|
||||
}); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if !bytes.HasPrefix(buf.Bytes(), []byte("[")) {
|
||||
t.Errorf("expected bare JSON array, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSON_BareObject(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
if err := format.WriteJSON(buf, map[string]any{"id": "kb_x", "name": "Engineering"}); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if !bytes.HasPrefix(buf.Bytes(), []byte("{")) {
|
||||
t.Errorf("expected bare JSON object, got %q", buf.String())
|
||||
}
|
||||
if bytes.Contains(buf.Bytes(), []byte(`"ok":`)) || bytes.Contains(buf.Bytes(), []byte(`"data":`)) {
|
||||
t.Errorf("bare output must not carry envelope keys: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONFiltered_FieldsOnArray(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
items := []map[string]any{
|
||||
{"id": "1", "name": "alpha", "kb_id": "kb_x", "updated_at": "2026-01-01"},
|
||||
{"id": "2", "name": "beta", "kb_id": "kb_x", "updated_at": "2026-01-02"},
|
||||
}
|
||||
if err := format.WriteJSONFiltered(buf, items, []string{"id", "name"}, ""); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var got []map[string]string
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("parse: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("items len = %d, want 2", len(got))
|
||||
}
|
||||
for i, item := range got {
|
||||
if _, has := item["kb_id"]; has {
|
||||
t.Errorf("item[%d] should not have kb_id: %v", i, item)
|
||||
}
|
||||
if item["id"] == "" || item["name"] == "" {
|
||||
t.Errorf("item[%d] missing kept fields: %v", i, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONFiltered_FieldsOnObject(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
obj := map[string]any{"id": "kb_x", "name": "Engineering", "owner": "alice"}
|
||||
if err := format.WriteJSONFiltered(buf, obj, []string{"id", "name"}, ""); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("parse: %v\n%s", err, buf.String())
|
||||
}
|
||||
if _, has := got["owner"]; has {
|
||||
t.Errorf("should not retain owner: %v", got)
|
||||
}
|
||||
if got["id"] != "kb_x" || got["name"] != "Engineering" {
|
||||
t.Errorf("kept fields missing: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONFiltered_UnknownFieldSilent(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
if err := format.WriteJSONFiltered(buf, map[string]any{"id": "1"}, []string{"id", "nonexistent"}, ""); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got["id"] != "1" {
|
||||
t.Errorf("id missing: %v", got)
|
||||
}
|
||||
if _, has := got["nonexistent"]; has {
|
||||
t.Errorf("nonexistent should be silently dropped: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONFiltered_JQOnly(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
items := []map[string]any{
|
||||
{"id": "1", "name": "alpha"},
|
||||
{"id": "2", "name": "beta"},
|
||||
}
|
||||
if err := format.WriteJSONFiltered(buf, items, nil, ".[].id"); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
// String / scalar results render without JSON quotes so scalar
|
||||
// projections (e.g. `--jq '.[].id'`) pipe cleanly into shell tools.
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
if len(lines) != 2 || lines[0] != "1" || lines[1] != "2" {
|
||||
t.Errorf("jq output mismatch: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONFiltered_FieldsAndJQ(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
items := []map[string]any{
|
||||
{"id": "1", "name": "alpha", "secret": "drop-me"},
|
||||
{"id": "2", "name": "beta", "secret": "drop-me"},
|
||||
}
|
||||
// Field filter first → then jq selects from filtered shape.
|
||||
if err := format.WriteJSONFiltered(buf, items, []string{"id"}, ".[].id"); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "drop-me") {
|
||||
t.Errorf("field filter must drop unrequested keys before jq: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONFiltered_NilDataPassthrough(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
if err := format.WriteJSONFiltered(buf, nil, []string{"id"}, ""); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(buf.String()) != "null" {
|
||||
t.Errorf("nil should marshal to bare null, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package format
|
||||
|
||||
// DashIfEmpty returns "-" for empty strings, otherwise s itself. Standard
|
||||
// rendering convention for human-readable table cells whose value is
|
||||
// optional (e.g. user email in `auth list` / `profile list`).
|
||||
func DashIfEmpty(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package format
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
)
|
||||
|
||||
// filterArrayItems applies filterObjectKeys to each element of an array.
|
||||
// Non-object elements (e.g. an array of strings) are passed through.
|
||||
func filterArrayItems(arrayRaw json.RawMessage, fields []string) (json.RawMessage, error) {
|
||||
var items []json.RawMessage
|
||||
if err := json.Unmarshal(arrayRaw, &items); err != nil {
|
||||
return nil, fmt.Errorf("field filter: parse array: %w", err)
|
||||
}
|
||||
for i, item := range items {
|
||||
trimmed := bytes.TrimSpace(item)
|
||||
if len(trimmed) == 0 || trimmed[0] != '{' {
|
||||
continue
|
||||
}
|
||||
filtered, err := filterObjectKeys(item, fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items[i] = filtered
|
||||
}
|
||||
return json.Marshal(items)
|
||||
}
|
||||
|
||||
// filterObjectKeys produces a new object containing only the listed keys
|
||||
// that were present in the source.
|
||||
func filterObjectKeys(objRaw json.RawMessage, fields []string) (json.RawMessage, error) {
|
||||
var src map[string]json.RawMessage
|
||||
if err := json.Unmarshal(objRaw, &src); err != nil {
|
||||
return nil, fmt.Errorf("field filter: parse object keys: %w", err)
|
||||
}
|
||||
dst := make(map[string]json.RawMessage, len(fields))
|
||||
for _, k := range fields {
|
||||
if v, ok := src[k]; ok {
|
||||
dst[k] = v
|
||||
}
|
||||
}
|
||||
return json.Marshal(dst)
|
||||
}
|
||||
|
||||
// JQError marks a failure attributable to the user-supplied --jq expression
|
||||
// (parse or evaluation), as opposed to an internal serialization fault. The
|
||||
// cmdutil layer (which can't be imported here without a cycle) maps it to
|
||||
// input.invalid_argument so agents fix the expression rather than treating a
|
||||
// bad --jq as a CLI bug (internal.error / exit 1).
|
||||
type JQError struct{ Err error }
|
||||
|
||||
func (e *JQError) Error() string { return e.Err.Error() }
|
||||
func (e *JQError) Unwrap() error { return e.Err }
|
||||
|
||||
// writeJQ evaluates expr against raw and writes each result line by line to w.
|
||||
// String results render without quotes (so `--jq '.name'` yields shell-friendly
|
||||
// bare strings); non-string results use encoding/json.
|
||||
//
|
||||
// Expression-attributable failures (parse / eval) are wrapped in *JQError so
|
||||
// the caller can map them to a typed input.invalid_argument code. A failure to
|
||||
// parse `raw` itself is internal (our own envelope JSON) and stays plain.
|
||||
func writeJQ(w io.Writer, raw []byte, expr string) error {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return &JQError{Err: fmt.Errorf("jq parse: %w", err)}
|
||||
}
|
||||
var input any
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
return fmt.Errorf("jq input parse: %w", err)
|
||||
}
|
||||
iter := query.Run(input)
|
||||
for {
|
||||
v, ok := iter.Next()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if e, ok := v.(error); ok {
|
||||
return &JQError{Err: fmt.Errorf("jq eval: %w", e)}
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
if _, err := fmt.Fprintln(w, s); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
out, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(append(out, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package format_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
)
|
||||
|
||||
// A malformed --jq expression must surface as *format.JQError so the cmdutil
|
||||
// layer can map it to a typed input.invalid_argument (exit 5) instead of the
|
||||
// unclassified internal.error bucket (exit 1). Regression for the bad-jq
|
||||
// misclassification.
|
||||
func TestWriteJSONFiltered_BadJQExpr_IsJQError(t *testing.T) {
|
||||
for _, expr := range []string{".data[", "this is not jq"} {
|
||||
buf := &bytes.Buffer{}
|
||||
err := format.WriteJSONFiltered(buf, map[string]any{"data": []any{}}, nil, expr)
|
||||
if err == nil {
|
||||
t.Fatalf("expr %q: expected error, got nil", expr)
|
||||
}
|
||||
var jqe *format.JQError
|
||||
if !errors.As(err, &jqe) {
|
||||
t.Errorf("expr %q: expected *format.JQError, got %T: %v", expr, err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A jq eval failure (valid parse, runtime error) is also expression-attributable
|
||||
// and must be a *JQError.
|
||||
func TestWriteJSONFiltered_JQEvalError_IsJQError(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
// ".foo.bar" against a non-object .foo raises an eval error.
|
||||
err := format.WriteJSONFiltered(buf, map[string]any{"foo": 5}, nil, ".foo.bar")
|
||||
if err == nil {
|
||||
t.Fatal("expected eval error, got nil")
|
||||
}
|
||||
var jqe *format.JQError
|
||||
if !errors.As(err, &jqe) {
|
||||
t.Errorf("expected *format.JQError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A valid expression must NOT error.
|
||||
func TestWriteJSONFiltered_ValidJQ_NoError(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
if err := format.WriteJSONFiltered(buf, map[string]any{"data": []any{"a", "b"}}, nil, ".data[]"); err != nil {
|
||||
t.Fatalf("valid jq errored: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package format
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// WriteNDJSON serializes v as NDJSON: one JSON value per line, '\n' terminated.
|
||||
// If v is a slice or array, each element gets its own line; otherwise v is
|
||||
// emitted as a single line. Per http://ndjson.org.
|
||||
//
|
||||
// HTML escaping is disabled (via WriteJSON) so '<', '>', '&' stay literal —
|
||||
// cleaner for agent stream-parsers that don't need HTML-safe output.
|
||||
func WriteNDJSON(w io.Writer, v any) error {
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
if err := WriteJSON(w, rv.Index(i).Interface()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return WriteJSON(w, v)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package format_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
)
|
||||
|
||||
func TestWriteNDJSON_ArraySplitsPerElement(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
arr := []map[string]any{{"id": "1"}, {"id": "2"}, {"id": "3"}}
|
||||
if err := format.WriteNDJSON(&buf, arr); err != nil {
|
||||
t.Fatalf("WriteNDJSON: %v", err)
|
||||
}
|
||||
want := `{"id":"1"}` + "\n" + `{"id":"2"}` + "\n" + `{"id":"3"}` + "\n"
|
||||
if buf.String() != want {
|
||||
t.Errorf("got %q want %q", buf.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNDJSON_SingleRecord(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
rec := map[string]string{"id": "abc"}
|
||||
if err := format.WriteNDJSON(&buf, rec); err != nil {
|
||||
t.Fatalf("WriteNDJSON: %v", err)
|
||||
}
|
||||
want := `{"id":"abc"}` + "\n"
|
||||
if buf.String() != want {
|
||||
t.Errorf("got %q want %q", buf.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNDJSON_EmptySlice(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := format.WriteNDJSON(&buf, []string{}); err != nil {
|
||||
t.Fatalf("WriteNDJSON: %v", err)
|
||||
}
|
||||
if buf.String() != "" {
|
||||
t.Errorf("got %q want empty", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNDJSON_TypedSlice(t *testing.T) {
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := format.WriteNDJSON(&buf, []item{{ID: "x"}, {ID: "y"}}); err != nil {
|
||||
t.Fatalf("WriteNDJSON: %v", err)
|
||||
}
|
||||
want := `{"id":"x"}` + "\n" + `{"id":"y"}` + "\n"
|
||||
if buf.String() != want {
|
||||
t.Errorf("got %q want %q", buf.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNDJSON_NoHTMLEscape(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := format.WriteNDJSON(&buf, map[string]string{"k": "a<b>c&d"}); err != nil {
|
||||
t.Fatalf("WriteNDJSON: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "a<b>c&d") {
|
||||
t.Errorf("expected unescaped HTML chars, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package format
|
||||
|
||||
import sdk "github.com/Tencent/WeKnora/client"
|
||||
|
||||
// ReferenceIndex is the bounded citation pointer exposed by projected JSON,
|
||||
// text, and MCP output. ChunkID is the chunk the model cited; ParentChunkID is
|
||||
// retained when callers prefer to fetch the larger self-contained passage.
|
||||
// Full chunk text and retrieval metadata remain available only in raw NDJSON.
|
||||
type ReferenceIndex struct {
|
||||
KBID string `json:"kb_id,omitempty"`
|
||||
ChunkID string `json:"chunk_id"`
|
||||
ParentChunkID string `json:"parent_chunk_id,omitempty"`
|
||||
}
|
||||
|
||||
// IndexReferences projects full SDK search results into stable lookup keys.
|
||||
// It never mutates the SDK events, which keeps the raw NDJSON path lossless.
|
||||
// fallbackKBID is used by `chat`, whose single KB is known by the CLI even
|
||||
// when an older server omits knowledge_base_id from a reference.
|
||||
func IndexReferences(refs []*sdk.SearchResult, fallbackKBID string) []ReferenceIndex {
|
||||
indexes := make([]ReferenceIndex, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
if r == nil || r.ID == "" {
|
||||
continue
|
||||
}
|
||||
kbID := r.KnowledgeBaseID
|
||||
if kbID == "" {
|
||||
kbID = fallbackKBID
|
||||
}
|
||||
indexes = append(indexes, ReferenceIndex{
|
||||
KBID: kbID,
|
||||
ChunkID: r.ID,
|
||||
ParentChunkID: r.ParentChunkID,
|
||||
})
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package format
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
func TestIndexReferences_ProjectsLookupFieldsWithoutMutation(t *testing.T) {
|
||||
refs := []*sdk.SearchResult{
|
||||
{ID: "c1", Content: "bulky passage one", KnowledgeBaseID: "kb1", ParentChunkID: "p1", KnowledgeTitle: "Doc One", Score: 0.5},
|
||||
{ID: "c2", Content: "bulky passage two", ParentChunkID: "p2"},
|
||||
nil,
|
||||
}
|
||||
got := IndexReferences(refs, "fallback")
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("indexes=%d, want 2", len(got))
|
||||
}
|
||||
if got[0].KBID != "kb1" || got[0].ChunkID != "c1" || got[0].ParentChunkID != "p1" {
|
||||
t.Errorf("first index=%+v", got[0])
|
||||
}
|
||||
if got[1].KBID != "fallback" || got[1].ChunkID != "c2" {
|
||||
t.Errorf("fallback index=%+v", got[1])
|
||||
}
|
||||
if refs[0].Content != "bulky passage one" || refs[1].Content != "bulky passage two" {
|
||||
t.Errorf("source references mutated: %+v", refs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexReferences_NilSafe(t *testing.T) {
|
||||
if got := IndexReferences(nil, ""); len(got) != 0 {
|
||||
t.Errorf("nil indexes=%+v", got)
|
||||
}
|
||||
if got := IndexReferences([]*sdk.SearchResult{nil, nil}, ""); len(got) != 0 {
|
||||
t.Errorf("nil-entry indexes=%+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package iostreams wraps stdout/stderr/stdin with TTY detection and color awareness.
|
||||
//
|
||||
// Tests swap the package-level singleton via SetForTest; production code reads
|
||||
// the global IO directly. (Tests must not call SetForTest from t.Parallel(),
|
||||
// since the swap is process-global.)
|
||||
package iostreams
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
)
|
||||
|
||||
// IOStreams holds standard I/O handles plus environment-derived flags.
|
||||
type IOStreams struct {
|
||||
In io.Reader
|
||||
Out io.Writer
|
||||
Err io.Writer
|
||||
|
||||
stdoutTTY bool
|
||||
stderrTTY bool
|
||||
}
|
||||
|
||||
// IO is the package-level singleton.
|
||||
var IO *IOStreams = newProduction()
|
||||
|
||||
func newProduction() *IOStreams {
|
||||
return &IOStreams{
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
Err: os.Stderr,
|
||||
stdoutTTY: isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()),
|
||||
stderrTTY: isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd()),
|
||||
}
|
||||
}
|
||||
|
||||
// IsStdoutTTY reports whether stdout is a terminal.
|
||||
func (s *IOStreams) IsStdoutTTY() bool { return s.stdoutTTY }
|
||||
|
||||
// IsStderrTTY reports whether stderr is a terminal.
|
||||
func (s *IOStreams) IsStderrTTY() bool { return s.stderrTTY }
|
||||
|
||||
// SetForTest replaces the package singleton with in-memory buffers for the
|
||||
// duration of t. Call it once per test. Not safe under t.Parallel().
|
||||
func SetForTest(t *testing.T) (out, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
saved := IO
|
||||
out = &bytes.Buffer{}
|
||||
errBuf = &bytes.Buffer{}
|
||||
IO = &IOStreams{In: bytes.NewReader(nil), Out: out, Err: errBuf}
|
||||
t.Cleanup(func() { IO = saved })
|
||||
return out, errBuf
|
||||
}
|
||||
|
||||
// SetForTestWithTTY is like SetForTest but reports IsStdoutTTY/IsStderrTTY
|
||||
// = true so tests can exercise interactive code paths (confirm prompts,
|
||||
// color, fancy renderers). Not safe under t.Parallel().
|
||||
func SetForTestWithTTY(t *testing.T) (out, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
saved := IO
|
||||
out = &bytes.Buffer{}
|
||||
errBuf = &bytes.Buffer{}
|
||||
IO = &IOStreams{
|
||||
In: bytes.NewReader(nil),
|
||||
Out: out,
|
||||
Err: errBuf,
|
||||
stdoutTTY: true,
|
||||
stderrTTY: true,
|
||||
}
|
||||
t.Cleanup(func() { IO = saved })
|
||||
return out, errBuf
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package iostreams
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSetForTest(t *testing.T) {
|
||||
out, errBuf := SetForTest(t)
|
||||
IO.Out.Write([]byte("stdout"))
|
||||
IO.Err.Write([]byte("stderr"))
|
||||
assert.Equal(t, "stdout", out.String())
|
||||
assert.Equal(t, "stderr", errBuf.String())
|
||||
}
|
||||
|
||||
func TestSetForTest_RestoresSingleton(t *testing.T) {
|
||||
original := IO
|
||||
t.Run("nested", func(t *testing.T) {
|
||||
SetForTest(t)
|
||||
assert.NotSame(t, original, IO)
|
||||
})
|
||||
// After the subtest's t.Cleanup runs, IO must be restored.
|
||||
assert.Same(t, original, IO)
|
||||
}
|
||||
|
||||
func TestProductionGetters(t *testing.T) {
|
||||
// We can't reliably assert the *value* of these (depends on whether tests
|
||||
// run in a TTY) but we can ensure the methods don't panic and return
|
||||
// consistent values across calls.
|
||||
p := newProduction()
|
||||
a, b := p.IsStdoutTTY(), p.IsStderrTTY()
|
||||
assert.Equal(t, a, p.IsStdoutTTY())
|
||||
assert.Equal(t, b, p.IsStderrTTY())
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package mcp wires the curated weknora tool set to an
|
||||
// modelcontextprotocol/go-sdk server. RunStdio is the entry point invoked
|
||||
// by `weknora mcp serve`.
|
||||
//
|
||||
// Design notes:
|
||||
//
|
||||
// - Tool surface is hand-curated rather than auto-derived from the cobra
|
||||
// tree (which would expose auth/link/completion/destructive verbs that
|
||||
// don't belong on an agent-callable surface).
|
||||
// - Long-running tools (chat / session_ask) accumulate the LLM SSE
|
||||
// stream server-side and return a single CallToolResult - MCP spec
|
||||
// 2025-06-18 does not define streamed tool-result content, so the
|
||||
// accumulate-and-return pattern is the canonical path.
|
||||
// - Handlers receive ctx for cancellation; mid-LLM-stream cancellation
|
||||
// propagates to the SDK via context, which closes the SSE connection.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/build"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// ServiceClient bundles the SDK methods the tool registry needs. *sdk.Client
|
||||
// satisfies it; tests substitute a fake to exercise the tool handlers
|
||||
// in-process without standing up a real WeKnora server.
|
||||
//
|
||||
// Embedding the full SDK Client would couple every tool test to every SDK
|
||||
// method; declaring the narrow surface here keeps the seam tight.
|
||||
type ServiceClient interface {
|
||||
knowledgeBaseService
|
||||
knowledgeService
|
||||
chatService
|
||||
agentService
|
||||
chunkListService
|
||||
}
|
||||
|
||||
var _ ServiceClient = (*sdk.Client)(nil)
|
||||
|
||||
// RunStdio constructs the MCP server, registers the curated 10 tools, and
|
||||
// blocks reading JSON-RPC from stdin until the client disconnects or ctx
|
||||
// is cancelled. Returns the underlying transport error (if any); the cobra
|
||||
// RunE caller maps it through the usual cmdutil exit-code path.
|
||||
func RunStdio(ctx context.Context, svc ServiceClient) error {
|
||||
v, _, _ := build.Info()
|
||||
server := mcpsdk.NewServer(
|
||||
&mcpsdk.Implementation{
|
||||
Name: "weknora",
|
||||
Version: v,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
registerTools(server, svc)
|
||||
if err := server.Run(ctx, &mcpsdk.StdioTransport{}); err != nil {
|
||||
// An MCP host shuts the server down by closing stdin (EOF) or cancelling
|
||||
// the context — that is normal termination, not a failure, so exit 0.
|
||||
// Only a genuine transport fault is surfaced as an error.
|
||||
if isCleanShutdown(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("mcp serve: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isCleanShutdown reports whether err is the ordinary "client went away"
|
||||
// signal: EOF on stdin or a cancelled/expired context. The go-sdk wraps the
|
||||
// stdin EOF in a formatted string ("server is closing: EOF") that does not
|
||||
// always unwrap to io.EOF, so we also match the message suffix as a fallback.
|
||||
func isCleanShutdown(err error) bool {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(strings.TrimSpace(err.Error()), "EOF")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsCleanShutdown pins that an MCP host disconnecting (stdin EOF) or a
|
||||
// cancelled context is treated as normal termination (exit 0), while a genuine
|
||||
// transport fault is not.
|
||||
func TestIsCleanShutdown(t *testing.T) {
|
||||
clean := []error{
|
||||
io.EOF,
|
||||
context.Canceled,
|
||||
context.DeadlineExceeded,
|
||||
fmt.Errorf("jsonrpc: %w", io.EOF), // wrapped EOF
|
||||
errors.New("server is closing: EOF"), // go-sdk formatted string (no unwrap)
|
||||
}
|
||||
for _, e := range clean {
|
||||
if !isCleanShutdown(e) {
|
||||
t.Errorf("isCleanShutdown(%v) = false, want true", e)
|
||||
}
|
||||
}
|
||||
notClean := []error{
|
||||
errors.New("connection reset by peer"),
|
||||
errors.New("write: broken pipe"),
|
||||
errors.New("EOF while reading header but more expected"), // EOF not at end
|
||||
}
|
||||
for _, e := range notClean {
|
||||
if isCleanShutdown(e) {
|
||||
t.Errorf("isCleanShutdown(%v) = true, want false", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSkillReferencedMCPToolsExist is part of the K6 drift guard: the MCP tool
|
||||
// names the bundled `weknora-shared` skill advertises (in its "CLI vs MCP"
|
||||
// section) must all be registered by registerTools. Guards against a skill
|
||||
// naming a renamed/removed tool (e.g. the agent_invoke→session_ask rename).
|
||||
func TestSkillReferencedMCPToolsExist(t *testing.T) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
skillPath := filepath.Join(filepath.Dir(file), "..", "..", "skills", "weknora-shared", "SKILL.md")
|
||||
raw, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read skill: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
|
||||
// Slice the "CLI vs MCP" section (heading → next "## " heading).
|
||||
start := strings.Index(body, "CLI vs MCP")
|
||||
if start < 0 {
|
||||
t.Fatal("weknora-shared SKILL.md has no 'CLI vs MCP' section to validate")
|
||||
}
|
||||
section := body[start:]
|
||||
if end := strings.Index(section, "\n## "); end >= 0 {
|
||||
section = section[:end]
|
||||
}
|
||||
|
||||
// Tool names are backtick-quoted snake_case identifiers in that section.
|
||||
tokenRe := regexp.MustCompile("`([a-z][a-z_]*)`")
|
||||
referenced := map[string]bool{}
|
||||
for _, m := range tokenRe.FindAllStringSubmatch(section, -1) {
|
||||
tok := m[1]
|
||||
if strings.Contains(tok, "_") || tok == "chat" { // tool-shaped (skip prose like `cli`)
|
||||
referenced[tok] = true
|
||||
}
|
||||
}
|
||||
if len(referenced) == 0 {
|
||||
t.Fatal("found no MCP tool names in the skill's CLI vs MCP section")
|
||||
}
|
||||
|
||||
// Live registered tool set.
|
||||
c, _ := newTestServer(t, &fakeSvc{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTools: %v", err)
|
||||
}
|
||||
registered := map[string]bool{}
|
||||
for _, tool := range res.Tools {
|
||||
registered[tool.Name] = true
|
||||
}
|
||||
|
||||
for name := range referenced {
|
||||
if !registered[name] {
|
||||
t.Errorf("weknora-shared skill references MCP tool %q which is NOT registered (renamed/removed?)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/sse"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// toolErrorResult builds an error CallToolResult with IsError=true, a
|
||||
// human-readable text fallback (Content), and the error envelope payload
|
||||
// (StructuredContent). Reuses cmdutil.ErrorToDetail so the hint / retry /
|
||||
// risk / detail fallback table stays single-source.
|
||||
//
|
||||
// Returns nil when err is nil; callers should only invoke on a real error.
|
||||
//
|
||||
// CAVEAT (go-sdk v1.6.0): SetError(err) clobbers Content; we manually
|
||||
// build CallToolResult instead.
|
||||
func toolErrorResult(err error) *mcpsdk.CallToolResult {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
detail := cmdutil.ErrorToDetail(err)
|
||||
textLine := detail.Type + ": " + detail.Message
|
||||
if detail.Hint != "" {
|
||||
textLine += "\nhint: " + detail.Hint
|
||||
}
|
||||
if len(detail.RetryArgv) != 0 {
|
||||
textLine += "\nretry: " + strings.Join(detail.RetryArgv, " ")
|
||||
}
|
||||
// StructuredContent accepts any; pass *ErrDetail directly (no round-trip).
|
||||
return &mcpsdk.CallToolResult{
|
||||
IsError: true,
|
||||
Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: textLine}},
|
||||
StructuredContent: detail,
|
||||
}
|
||||
}
|
||||
|
||||
func streamErrorDetail(sessionID, assistantMessageID string) map[string]any {
|
||||
detail := map[string]any{"session_id": sessionID}
|
||||
if assistantMessageID != "" {
|
||||
detail["assistant_message_id"] = assistantMessageID
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func toolStreamError(err *cmdutil.Error, sessionID, assistantMessageID string) *mcpsdk.CallToolResult {
|
||||
return toolErrorResult(err.WithDetail(streamErrorDetail(sessionID, assistantMessageID)))
|
||||
}
|
||||
|
||||
// successResult builds a CallToolResult with StructuredContent = payload.
|
||||
// Using Out=any in all handlers disables the SDK auto-marshal path (which
|
||||
// would overwrite our StructuredContent with a zero-struct when the handler
|
||||
// returns a typed nil). We manually populate both StructuredContent and a
|
||||
// text Content fallback so the shape is identical on success and error.
|
||||
func successResult(payload any) *mcpsdk.CallToolResult {
|
||||
return &mcpsdk.CallToolResult{
|
||||
StructuredContent: payload,
|
||||
Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: marshalToString(payload)}},
|
||||
}
|
||||
}
|
||||
|
||||
func marshalToString(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Narrow per-domain service interfaces. ServiceClient (server.go) embeds
|
||||
// them all; *sdk.Client satisfies the union implicitly.
|
||||
|
||||
type knowledgeBaseService interface {
|
||||
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
|
||||
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
type knowledgeService interface {
|
||||
ListKnowledgeWithFilter(ctx context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error)
|
||||
GetKnowledge(ctx context.Context, knowledgeID string) (*sdk.Knowledge, error)
|
||||
OpenKnowledgeFile(ctx context.Context, knowledgeID string) (string, io.ReadCloser, error)
|
||||
HybridSearch(
|
||||
ctx context.Context,
|
||||
kbID string,
|
||||
params *sdk.SearchParams,
|
||||
opts ...sdk.ResourceURLOptions,
|
||||
) ([]*sdk.SearchResult, error)
|
||||
}
|
||||
|
||||
type chatService interface {
|
||||
CreateSession(ctx context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error)
|
||||
KnowledgeQAStream(ctx context.Context, sessionID string, req *sdk.KnowledgeQARequest, cb func(*sdk.StreamResponse) error, opts ...sdk.ResourceURLOptions) error
|
||||
}
|
||||
|
||||
type agentService interface {
|
||||
ListAgents(ctx context.Context) ([]sdk.Agent, error)
|
||||
GetAgent(ctx context.Context, agentID string) (*sdk.Agent, error)
|
||||
AgentQAStreamWithRequest(ctx context.Context, sessionID string, req *sdk.AgentQARequest, cb sdk.AgentEventCallback, opts ...sdk.ResourceURLOptions) error
|
||||
}
|
||||
|
||||
// chunkListService is the narrow surface chunk_list depends on. Kept
|
||||
// separate from knowledgeService because the chunk subtree is its own
|
||||
// domain on the server side (/api/v1/chunks/...).
|
||||
type chunkListService interface {
|
||||
ListKnowledgeChunks(ctx context.Context, knowledgeID string, page, pageSize int, chunkTypes ...string) ([]sdk.Chunk, int64, error)
|
||||
}
|
||||
|
||||
// sessionAskService composes the two SDK methods session_ask needs
|
||||
// (CreateSession for the auto-session path + AgentQAStreamWithRequest
|
||||
// for the run itself). Declared here alongside the per-domain
|
||||
// interfaces above so ServiceClient (server.go) - which embeds the
|
||||
// four domain interfaces - also satisfies it.
|
||||
type sessionAskService interface {
|
||||
CreateSession(ctx context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error)
|
||||
AgentQAStreamWithRequest(ctx context.Context, sessionID string, req *sdk.AgentQARequest, cb sdk.AgentEventCallback, opts ...sdk.ResourceURLOptions) error
|
||||
}
|
||||
|
||||
// registerTools wires the curated 10 tools onto server. Adding a tool here
|
||||
// is a deliberate API expansion - the agent-callable surface is the
|
||||
// reason this CLI ships an MCP server, not its CLI command list, so this
|
||||
// list must be maintained by hand.
|
||||
//
|
||||
// TODO: add OutputSchema to each mcpsdk.Tool registration so agents can
|
||||
// type-check responses without structural probing. Currently omitted
|
||||
// because the Out type is `any` on all handlers (required to suppress
|
||||
// the SDK's auto-marshal which clobbers our manually-populated
|
||||
// StructuredContent). When go-sdk exposes a typed OutputSchema field
|
||||
// independent of the handler Out type, populate it from the
|
||||
// corresponding *Output struct.
|
||||
func registerTools(server *mcpsdk.Server, svc ServiceClient) {
|
||||
addKBList(server, svc)
|
||||
addKBView(server, svc)
|
||||
addDocList(server, svc)
|
||||
addDocView(server, svc)
|
||||
addDocDownload(server, svc)
|
||||
addSearchChunks(server, svc)
|
||||
addChat(server, svc)
|
||||
addAgentList(server, svc)
|
||||
addSessionAsk(server, svc)
|
||||
addChunkList(server, svc)
|
||||
}
|
||||
|
||||
// ---- kb_list -------------------------------------------------------------
|
||||
|
||||
type kbListInput struct{}
|
||||
|
||||
type kbListOutput struct {
|
||||
Items []sdk.KnowledgeBase `json:"items"`
|
||||
}
|
||||
|
||||
func addKBList(server *mcpsdk.Server, svc knowledgeBaseService) {
|
||||
mcpsdk.AddTool(server, &mcpsdk.Tool{
|
||||
Name: "kb_list",
|
||||
Description: "List all knowledge bases visible to the active WeKnora tenant. No arguments. Returns items[]: each item carries id, name, description, knowledge_count, is_pinned, updated_at - useful for selecting a kb_id to pass to other tools.",
|
||||
Annotations: &mcpsdk.ToolAnnotations{
|
||||
Title: "List Knowledge Bases",
|
||||
DestructiveHint: bptr(false),
|
||||
ReadOnlyHint: true,
|
||||
IdempotentHint: true,
|
||||
OpenWorldHint: bptr(false),
|
||||
},
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ kbListInput) (*mcpsdk.CallToolResult, any, error) {
|
||||
items, err := svc.ListKnowledgeBases(ctx)
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.WrapHTTP(err, "list knowledge bases")), nil, nil
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.KnowledgeBase{}
|
||||
}
|
||||
return successResult(kbListOutput{Items: items}), nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---- kb_view -------------------------------------------------------------
|
||||
|
||||
type kbViewInput struct {
|
||||
KBID string `json:"kb_id" jsonschema:"knowledge base ID"`
|
||||
}
|
||||
|
||||
func addKBView(server *mcpsdk.Server, svc knowledgeBaseService) {
|
||||
mcpsdk.AddTool(server, &mcpsdk.Tool{
|
||||
Name: "kb_view",
|
||||
Description: "Fetch a knowledge base by ID. Returns the full record including chunking config, embedding/summary model IDs, knowledge_count, and chunk_count.",
|
||||
Annotations: &mcpsdk.ToolAnnotations{
|
||||
Title: "View Knowledge Base",
|
||||
DestructiveHint: bptr(false),
|
||||
ReadOnlyHint: true,
|
||||
IdempotentHint: true,
|
||||
OpenWorldHint: bptr(false),
|
||||
},
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in kbViewInput) (*mcpsdk.CallToolResult, any, error) {
|
||||
if in.KBID == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeKBIDRequired, "kb_id is required")), nil, nil
|
||||
}
|
||||
kb, err := svc.GetKnowledgeBase(ctx, in.KBID)
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.WrapHTTP(err, "get knowledge base")), nil, nil
|
||||
}
|
||||
return successResult(kb), nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---- doc_list ------------------------------------------------------------
|
||||
|
||||
type docListInput struct {
|
||||
KBID string `json:"kb_id" jsonschema:"knowledge base ID"`
|
||||
Page int `json:"page,omitempty" jsonschema:"1-indexed page number; defaults to 1"`
|
||||
PageSize int `json:"page_size,omitempty" jsonschema:"items per page (1..1000); defaults to 20"`
|
||||
Status string `json:"status,omitempty" jsonschema:"filter by parse status: pending | processing | completed | failed"`
|
||||
Keyword string `json:"keyword,omitempty" jsonschema:"server-side substring filter (case-sensitive LIKE against title / file_name); leave empty to skip"`
|
||||
FileType string `json:"file_type,omitempty" jsonschema:"filter by file extension (e.g. pdf, md)"`
|
||||
Source string `json:"source,omitempty" jsonschema:"filter by ingestion source (e.g. api, web)"`
|
||||
TagID string `json:"tag_id,omitempty" jsonschema:"filter by tag association"`
|
||||
StartTime string `json:"start_time,omitempty" jsonschema:"include docs with updated_at >= this RFC3339 timestamp (e.g. 2006-01-02T15:04:05Z)"`
|
||||
EndTime string `json:"end_time,omitempty" jsonschema:"include docs with updated_at <= this RFC3339 timestamp (e.g. 2006-01-02T15:04:05Z)"`
|
||||
}
|
||||
|
||||
type docListOutput struct {
|
||||
Items []sdk.Knowledge `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func addDocList(server *mcpsdk.Server, svc knowledgeService) {
|
||||
mcpsdk.AddTool(server, &mcpsdk.Tool{
|
||||
Name: "doc_list",
|
||||
Description: "List documents in a knowledge base, with pagination and optional filters (parse-status, keyword, file_type, source, tag_id, start_time/end_time on updated_at). Returns items[] with id, file_name, title, parse_status, size, updated_at - plus the page/total metadata.",
|
||||
Annotations: &mcpsdk.ToolAnnotations{
|
||||
Title: "List Documents",
|
||||
DestructiveHint: bptr(false),
|
||||
ReadOnlyHint: true,
|
||||
IdempotentHint: true,
|
||||
OpenWorldHint: bptr(false),
|
||||
},
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in docListInput) (*mcpsdk.CallToolResult, any, error) {
|
||||
if in.KBID == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeKBIDRequired, "kb_id is required")), nil, nil
|
||||
}
|
||||
page := in.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := in.PageSize
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 1000 {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "page_size must be in 1..1000")), nil, nil
|
||||
}
|
||||
filter := sdk.KnowledgeListFilter{
|
||||
ParseStatus: in.Status,
|
||||
Keyword: in.Keyword,
|
||||
FileType: in.FileType,
|
||||
Source: in.Source,
|
||||
TagID: in.TagID,
|
||||
}
|
||||
if in.StartTime != "" {
|
||||
t, err := time.Parse(time.RFC3339, in.StartTime)
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("start_time must be RFC3339 (e.g. 2006-01-02T15:04:05Z), got %q", in.StartTime))), nil, nil
|
||||
}
|
||||
filter.StartTime = t
|
||||
}
|
||||
if in.EndTime != "" {
|
||||
t, err := time.Parse(time.RFC3339, in.EndTime)
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("end_time must be RFC3339 (e.g. 2006-01-02T15:04:05Z), got %q", in.EndTime))), nil, nil
|
||||
}
|
||||
filter.EndTime = t
|
||||
}
|
||||
items, total, err := svc.ListKnowledgeWithFilter(ctx, in.KBID, page, size, filter)
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.WrapHTTP(err, "list documents")), nil, nil
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.Knowledge{}
|
||||
}
|
||||
return successResult(docListOutput{Items: items, Page: page, PageSize: size, Total: total}), nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---- doc_view ------------------------------------------------------------
|
||||
|
||||
type docViewInput struct {
|
||||
DocID string `json:"doc_id" jsonschema:"document ID (same value as the doc-id positional in CLI commands)"`
|
||||
}
|
||||
|
||||
func addDocView(server *mcpsdk.Server, svc knowledgeService) {
|
||||
mcpsdk.AddTool(server, &mcpsdk.Tool{
|
||||
Name: "doc_view",
|
||||
Description: "Fetch a single document by ID. Returns the Knowledge record (file_name, title, type, parse_status, size, embedding_model_id, source URL if any, etc.).",
|
||||
Annotations: &mcpsdk.ToolAnnotations{
|
||||
Title: "View Document",
|
||||
DestructiveHint: bptr(false),
|
||||
ReadOnlyHint: true,
|
||||
IdempotentHint: true,
|
||||
OpenWorldHint: bptr(false),
|
||||
},
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in docViewInput) (*mcpsdk.CallToolResult, any, error) {
|
||||
if in.DocID == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputMissingFlag, "doc_id is required")), nil, nil
|
||||
}
|
||||
k, err := svc.GetKnowledge(ctx, in.DocID)
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.WrapHTTP(err, "get knowledge")), nil, nil
|
||||
}
|
||||
return successResult(k), nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---- doc_download --------------------------------------------------------
|
||||
|
||||
type docDownloadInput struct {
|
||||
DocID string `json:"doc_id" jsonschema:"document ID (same value as the doc-id positional in CLI commands)"`
|
||||
}
|
||||
|
||||
type docDownloadOutput struct {
|
||||
DocID string `json:"doc_id"`
|
||||
FileName string `json:"file_name"`
|
||||
Bytes int `json:"bytes"`
|
||||
// Content is the file contents (UTF-8 if text, base64 if the SDK
|
||||
// reports a binary-looking blob). For binary, agents should decode
|
||||
// before consuming.
|
||||
Content string `json:"content"`
|
||||
IsBase64 bool `json:"is_base64"`
|
||||
}
|
||||
|
||||
// maxDocDownloadBytes caps the per-call payload to keep an agent's context
|
||||
// window safe; agents needing larger documents should chunk via doc_view +
|
||||
// search_chunks. 1 MiB matches a typical LLM context-window budget for
|
||||
// inline content (~250k tokens) while remaining cheap to serialize.
|
||||
const maxDocDownloadBytes = 1 << 20
|
||||
|
||||
func addDocDownload(server *mcpsdk.Server, svc knowledgeService) {
|
||||
mcpsdk.AddTool(server, &mcpsdk.Tool{
|
||||
Name: "doc_download",
|
||||
Description: "Download a document's raw bytes by ID. Capped at 1 MiB per call - for larger documents, use search_chunks to find the relevant excerpts. is_base64 reports whether content was base64-encoded (heuristic: presence of NUL byte in the first 512 bytes).",
|
||||
Annotations: &mcpsdk.ToolAnnotations{
|
||||
Title: "Download Document",
|
||||
DestructiveHint: bptr(false),
|
||||
ReadOnlyHint: true,
|
||||
IdempotentHint: true,
|
||||
OpenWorldHint: bptr(false),
|
||||
},
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in docDownloadInput) (*mcpsdk.CallToolResult, any, error) {
|
||||
if in.DocID == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputMissingFlag, "doc_id is required")), nil, nil
|
||||
}
|
||||
name, body, err := svc.OpenKnowledgeFile(ctx, in.DocID)
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.WrapHTTP(err, "open knowledge file")), nil, nil
|
||||
}
|
||||
defer body.Close()
|
||||
buf, err := io.ReadAll(io.LimitReader(body, maxDocDownloadBytes+1))
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read knowledge file")), nil, nil
|
||||
}
|
||||
if len(buf) > maxDocDownloadBytes {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("document exceeds the %d-byte per-call cap; use search_chunks for excerpts", maxDocDownloadBytes))), nil, nil
|
||||
}
|
||||
content, isBase64 := encodeDownload(buf)
|
||||
return successResult(docDownloadOutput{
|
||||
DocID: in.DocID,
|
||||
FileName: name,
|
||||
Bytes: len(buf),
|
||||
Content: content,
|
||||
IsBase64: isBase64,
|
||||
}), nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---- search_chunks -------------------------------------------------------
|
||||
|
||||
type searchChunksInput struct {
|
||||
KBID string `json:"kb_id" jsonschema:"knowledge base ID to search"`
|
||||
Query string `json:"query" jsonschema:"natural-language search query"`
|
||||
Limit int `json:"limit,omitempty" jsonschema:"client-side cap on results (1..1000); defaults to 10"`
|
||||
VectorThreshold float64 `json:"vector_threshold,omitempty" jsonschema:"minimum vector similarity (0..1)"`
|
||||
KeywordThreshold float64 `json:"keyword_threshold,omitempty" jsonschema:"minimum keyword score (0..1)"`
|
||||
}
|
||||
|
||||
type searchChunksOutput struct {
|
||||
Results []*sdk.SearchResult `json:"results"`
|
||||
}
|
||||
|
||||
func addSearchChunks(server *mcpsdk.Server, svc knowledgeService) {
|
||||
// Out = any: SDK output schema would derive from searchChunksOutput,
|
||||
// which embeds *sdk.SearchResult - and SearchResult.Metadata is a
|
||||
// nilable map[string]any that violates the auto-generated
|
||||
// type=object constraint when empty. Skipping derivation by using
|
||||
// `any` keeps the structured JSON shape identical while bypassing
|
||||
// the over-eager validator. Same pattern applied to chat / session_ask
|
||||
// below.
|
||||
mcpsdk.AddTool(server, &mcpsdk.Tool{
|
||||
Name: "search_chunks",
|
||||
Description: "Hybrid (vector + keyword) retrieval against a knowledge base. Returns the top chunks ranked by RRF; use this before chat to ground an answer in cited context. Results include knowledge_id, content, score - feed back into chat as context or display directly.",
|
||||
Annotations: &mcpsdk.ToolAnnotations{
|
||||
Title: "Search Knowledge Chunks",
|
||||
DestructiveHint: bptr(false),
|
||||
ReadOnlyHint: true,
|
||||
IdempotentHint: true,
|
||||
OpenWorldHint: bptr(false),
|
||||
},
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in searchChunksInput) (*mcpsdk.CallToolResult, any, error) {
|
||||
if in.KBID == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeKBIDRequired, "kb_id is required")), nil, nil
|
||||
}
|
||||
if strings.TrimSpace(in.Query) == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputMissingFlag, "query cannot be empty")), nil, nil
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit < 1 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 1000 {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "limit must be in 1..1000")), nil, nil
|
||||
}
|
||||
results, err := svc.HybridSearch(ctx, in.KBID, &sdk.SearchParams{
|
||||
QueryText: in.Query,
|
||||
MatchCount: limit,
|
||||
VectorThreshold: in.VectorThreshold,
|
||||
KeywordThreshold: in.KeywordThreshold,
|
||||
})
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.WrapHTTP(err, "hybrid search")), nil, nil
|
||||
}
|
||||
if len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
if results == nil {
|
||||
results = []*sdk.SearchResult{}
|
||||
}
|
||||
return successResult(searchChunksOutput{Results: results}), nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---- chat ----------------------------------------------------------------
|
||||
|
||||
type chatInput struct {
|
||||
KBID string `json:"kb_id" jsonschema:"knowledge base ID to chat against"`
|
||||
Query string `json:"query" jsonschema:"user query"`
|
||||
SessionID string `json:"session_id,omitempty" jsonschema:"existing session to continue; auto-created when empty"`
|
||||
Reference bool `json:"reference,omitempty" jsonschema:"include indexed references"`
|
||||
Verbose bool `json:"verbose,omitempty" jsonschema:"include reasoning, tools, and lifecycle events"`
|
||||
}
|
||||
|
||||
type chatOutput struct {
|
||||
Events []sse.ProjectedEvent `json:"events"`
|
||||
SessionID string `json:"session_id"`
|
||||
AssistantMessageID string `json:"assistant_message_id,omitempty"`
|
||||
KBID string `json:"kb_id"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
func addChat(server *mcpsdk.Server, svc chatService) {
|
||||
mcpsdk.AddTool(server, &mcpsdk.Tool{
|
||||
Name: "chat",
|
||||
Description: "Run a RAG answer against a knowledge base. Returns a bounded answer-event projection by default; reference=true adds indexed citations and verbose=true adds reasoning, tool, and lifecycle events. MCP tools/call is buffered rather than streaming. Pass session_id to continue a conversation.",
|
||||
Annotations: &mcpsdk.ToolAnnotations{
|
||||
Title: "Chat with KB (Streaming RAG)",
|
||||
DestructiveHint: bptr(false),
|
||||
ReadOnlyHint: false,
|
||||
IdempotentHint: false,
|
||||
OpenWorldHint: bptr(true),
|
||||
},
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in chatInput) (*mcpsdk.CallToolResult, any, error) {
|
||||
if in.KBID == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeKBIDRequired, "kb_id is required")), nil, nil
|
||||
}
|
||||
if strings.TrimSpace(in.Query) == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputMissingFlag, "query cannot be empty")), nil, nil
|
||||
}
|
||||
sessionID := in.SessionID
|
||||
if sessionID == "" {
|
||||
sess, err := svc.CreateSession(ctx, &sdk.CreateSessionRequest{Title: "weknora mcp chat"})
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.WrapHTTP(err, "create chat session")), nil, nil
|
||||
}
|
||||
sessionID = sess.ID
|
||||
}
|
||||
req := &sdk.KnowledgeQARequest{
|
||||
Query: in.Query,
|
||||
KnowledgeBaseIDs: []string{in.KBID},
|
||||
AgentEnabled: false,
|
||||
Channel: "api",
|
||||
}
|
||||
projector := sse.NewProjector(in.Verbose, in.Reference, in.KBID)
|
||||
events := make([]sse.ProjectedEvent, 0)
|
||||
streamErr := svc.KnowledgeQAStream(ctx, sessionID, req, func(r *sdk.StreamResponse) error {
|
||||
if event, include := projector.Chat(r); include {
|
||||
events = append(events, event)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if streamErr != nil {
|
||||
return toolStreamError(cmdutil.WrapStream(streamErr, "knowledge qa stream"), sessionID, projector.AssistantMessageID()), nil, nil
|
||||
}
|
||||
if !projector.Done() {
|
||||
return toolStreamError(cmdutil.NewError(cmdutil.CodeSSEStreamAborted, "stream ended without a terminal event"), sessionID, projector.AssistantMessageID()), nil, nil
|
||||
}
|
||||
sid := projector.SessionID()
|
||||
if sid == "" {
|
||||
sid = sessionID
|
||||
}
|
||||
return successResult(chatOutput{
|
||||
Events: events,
|
||||
SessionID: sid,
|
||||
AssistantMessageID: projector.AssistantMessageID(),
|
||||
KBID: in.KBID,
|
||||
Query: in.Query,
|
||||
}), nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---- agent_list ----------------------------------------------------------
|
||||
|
||||
type agentListInput struct{}
|
||||
|
||||
type agentListOutput struct {
|
||||
Items []sdk.Agent `json:"items"`
|
||||
}
|
||||
|
||||
func addAgentList(server *mcpsdk.Server, svc agentService) {
|
||||
mcpsdk.AddTool(server, &mcpsdk.Tool{
|
||||
Name: "agent_list",
|
||||
Description: "List the tenant's custom agents. Returns items[] with id, name, description, is_builtin - use to discover an agent_id before session_ask.",
|
||||
Annotations: &mcpsdk.ToolAnnotations{
|
||||
Title: "List Custom Agents",
|
||||
DestructiveHint: bptr(false),
|
||||
ReadOnlyHint: true,
|
||||
IdempotentHint: true,
|
||||
OpenWorldHint: bptr(false),
|
||||
},
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ agentListInput) (*mcpsdk.CallToolResult, any, error) {
|
||||
items, err := svc.ListAgents(ctx)
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.WrapHTTP(err, "list agents")), nil, nil
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.Agent{}
|
||||
}
|
||||
return successResult(agentListOutput{Items: items}), nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---- session_ask ---------------------------------------------------------
|
||||
|
||||
type sessionAskInput struct {
|
||||
AgentID string `json:"agent_id" jsonschema:"custom agent ID"`
|
||||
Query string `json:"query" jsonschema:"user query"`
|
||||
SessionID string `json:"session_id,omitempty" jsonschema:"existing session to continue; auto-created when empty"`
|
||||
Reference bool `json:"reference,omitempty" jsonschema:"include indexed references"`
|
||||
Verbose bool `json:"verbose,omitempty" jsonschema:"include reasoning, tools, and lifecycle events"`
|
||||
}
|
||||
|
||||
type sessionAskOutput struct {
|
||||
Events []sse.ProjectedEvent `json:"events"`
|
||||
SessionID string `json:"session_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
func addSessionAsk(server *mcpsdk.Server, svc sessionAskService) {
|
||||
mcpsdk.AddTool(server, &mcpsdk.Tool{
|
||||
Name: "session_ask",
|
||||
Description: "Run a query through a custom agent. Returns a bounded answer-event projection by default; reference=true adds indexed citations and verbose=true adds reasoning, tool, and lifecycle events. MCP tools/call is buffered rather than streaming.",
|
||||
Annotations: &mcpsdk.ToolAnnotations{
|
||||
Title: "Ask a Custom Agent (session ask --agent)",
|
||||
DestructiveHint: bptr(false),
|
||||
ReadOnlyHint: false,
|
||||
IdempotentHint: false,
|
||||
OpenWorldHint: bptr(true),
|
||||
},
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in sessionAskInput) (*mcpsdk.CallToolResult, any, error) {
|
||||
if in.AgentID == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputMissingFlag, "agent_id is required")), nil, nil
|
||||
}
|
||||
if strings.TrimSpace(in.Query) == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputMissingFlag, "query cannot be empty")), nil, nil
|
||||
}
|
||||
projector := sse.NewProjector(in.Verbose, in.Reference, "")
|
||||
events := make([]sse.ProjectedEvent, 0)
|
||||
req := &sdk.AgentQARequest{
|
||||
Query: in.Query,
|
||||
AgentEnabled: true,
|
||||
AgentID: in.AgentID,
|
||||
Channel: "api",
|
||||
}
|
||||
// Auto-create session if not supplied. Sessions are agent-
|
||||
// agnostic at creation (verified against server source).
|
||||
sessionID := in.SessionID
|
||||
if sessionID == "" {
|
||||
sess, err := svc.CreateSession(ctx, &sdk.CreateSessionRequest{Title: "weknora mcp session_ask"})
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.WrapHTTP(err, "create chat session")), nil, nil
|
||||
}
|
||||
sessionID = sess.ID
|
||||
}
|
||||
streamErr := svc.AgentQAStreamWithRequest(ctx, sessionID, req, func(r *sdk.AgentStreamResponse) error {
|
||||
if event, include := projector.Agent(r); include {
|
||||
events = append(events, event)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if streamErr != nil {
|
||||
return toolStreamError(cmdutil.WrapStream(streamErr, "agent-chat stream"), sessionID, ""), nil, nil
|
||||
}
|
||||
if !projector.Done() {
|
||||
return toolStreamError(cmdutil.NewError(cmdutil.CodeSSEStreamAborted, "stream ended without a terminal event"), sessionID, ""), nil, nil
|
||||
}
|
||||
return successResult(sessionAskOutput{
|
||||
Events: events,
|
||||
SessionID: sessionID,
|
||||
AgentID: in.AgentID,
|
||||
Query: in.Query,
|
||||
}), nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---- chunk_list ----------------------------------------------------------
|
||||
|
||||
type chunkListInput struct {
|
||||
DocID string `json:"doc_id" jsonschema:"document (knowledge entry) ID"`
|
||||
Limit int `json:"limit,omitempty" jsonschema:"max chunks to return (1..1000); defaults to 50"`
|
||||
}
|
||||
|
||||
type chunkListOutput struct {
|
||||
Chunks []sdk.Chunk `json:"chunks"`
|
||||
Total int64 `json:"total"`
|
||||
TruncatedAtLimit bool `json:"truncated_at_limit"`
|
||||
}
|
||||
|
||||
// chunkListDefaultLimit + chunkListMaxLimit mirror the schema's default+max.
|
||||
// MCP schema deliberately exposes only `limit`, not the CLI's full
|
||||
// --limit/--page/--page-size triple: LLM agents typically need a single
|
||||
// bounded fetch, not pagination workflows. Above 1000, fall back to the CLI.
|
||||
const (
|
||||
chunkListDefaultLimit = 50
|
||||
chunkListMaxLimit = 1000
|
||||
)
|
||||
|
||||
func addChunkList(server *mcpsdk.Server, svc chunkListService) {
|
||||
mcpsdk.AddTool(server, &mcpsdk.Tool{
|
||||
Name: "chunk_list",
|
||||
Description: "List chunks of a knowledge document for RAG retrieval debug. Returns at most `limit` chunks starting from ChunkIndex 0; if total chunks exceed limit, truncated_at_limit=true signals the agent to fall back to the CLI for paginated retrieval.",
|
||||
Annotations: &mcpsdk.ToolAnnotations{
|
||||
Title: "List Knowledge Chunks",
|
||||
DestructiveHint: bptr(false),
|
||||
ReadOnlyHint: true,
|
||||
IdempotentHint: true,
|
||||
OpenWorldHint: bptr(false),
|
||||
},
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in chunkListInput) (*mcpsdk.CallToolResult, any, error) {
|
||||
if in.DocID == "" {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputMissingFlag, "doc_id is required")), nil, nil
|
||||
}
|
||||
// `limit` is typed as int by chunkListInput, so the SDK rejects
|
||||
// non-numeric values at schema validation (e.g. "limit":"50")
|
||||
// before this handler runs. Default when unset; reject over-max
|
||||
// (rather than silently clamping) so the agent's request is never
|
||||
// quietly changed — matching search_chunks in this same file.
|
||||
limit := in.Limit
|
||||
if limit < 1 {
|
||||
limit = chunkListDefaultLimit
|
||||
}
|
||||
if limit > chunkListMaxLimit {
|
||||
return toolErrorResult(cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("limit must be in 1..%d", chunkListMaxLimit))), nil, nil
|
||||
}
|
||||
chunks, total, err := svc.ListKnowledgeChunks(ctx, in.DocID, 1, limit)
|
||||
if err != nil {
|
||||
return toolErrorResult(cmdutil.WrapHTTP(err, "list knowledge chunks")), nil, nil
|
||||
}
|
||||
if chunks == nil {
|
||||
chunks = []sdk.Chunk{}
|
||||
}
|
||||
return successResult(chunkListOutput{
|
||||
Chunks: chunks,
|
||||
Total: total,
|
||||
TruncatedAtLimit: total > int64(limit),
|
||||
}), nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// encodeDownload returns (content, isBase64). Heuristic: if the first 512
|
||||
// bytes contain a NUL, treat as binary. Otherwise it's UTF-8-ish text.
|
||||
// Matches what /usr/bin/file's "binary" heuristic does at a coarse level -
|
||||
// good enough to spare an agent from base64-decoding obvious text.
|
||||
func encodeDownload(buf []byte) (string, bool) {
|
||||
probe := buf
|
||||
if len(probe) > 512 {
|
||||
probe = probe[:512]
|
||||
}
|
||||
for _, b := range probe {
|
||||
if b == 0 {
|
||||
return base64.StdEncoding.EncodeToString(buf), true
|
||||
}
|
||||
}
|
||||
return string(buf), false
|
||||
}
|
||||
|
||||
// bptr returns a pointer to a bool literal. MCP ToolAnnotations uses
|
||||
// pointer types for DestructiveHint and OpenWorldHint so that "explicit
|
||||
// false" can be distinguished from "field omitted (default true per MCP
|
||||
// spec 2025-06-18)".
|
||||
func bptr(b bool) *bool { return &b }
|
||||
@@ -0,0 +1,105 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// TestToolErrorResult_TypedError_PopulatesStructuredContent verifies the
|
||||
// helper maps a typed cmdutil.Error to MCP StructuredContent as *output.ErrDetail.
|
||||
func TestToolErrorResult_TypedError_PopulatesStructuredContent(t *testing.T) {
|
||||
err := cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no creds")
|
||||
res := toolErrorResult(err)
|
||||
if !res.IsError {
|
||||
t.Errorf("expected IsError true")
|
||||
}
|
||||
if res.StructuredContent == nil {
|
||||
t.Fatal("expected StructuredContent populated")
|
||||
}
|
||||
sc, ok := res.StructuredContent.(*output.ErrDetail)
|
||||
if !ok {
|
||||
t.Fatalf("StructuredContent has unexpected type %T", res.StructuredContent)
|
||||
}
|
||||
if sc.Type != "auth.unauthenticated" {
|
||||
t.Errorf("type: got %v want auth.unauthenticated", sc.Type)
|
||||
}
|
||||
// retry_argv should fall back to ["weknora","auth","login"] via cmdutil default
|
||||
if !reflect.DeepEqual(sc.RetryArgv, []string{"weknora", "auth", "login"}) {
|
||||
t.Errorf("retry_argv: got %v", sc.RetryArgv)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolErrorResult_TextFallback verifies the human-readable Content[0]
|
||||
// contains code + message + (hint? + retry?) lines.
|
||||
func TestToolErrorResult_TextFallback(t *testing.T) {
|
||||
err := cmdutil.NewError(cmdutil.CodeKBIDRequired, "kb_id is required")
|
||||
res := toolErrorResult(err)
|
||||
if len(res.Content) == 0 {
|
||||
t.Fatal("expected Content[0]")
|
||||
}
|
||||
// Assertion is best-effort on the text shape; exact prose may vary
|
||||
// with hint/retry defaults. Lock the type code prefix.
|
||||
text := contentText(t, res.Content[0])
|
||||
if !strings.Contains(text, "local.kb_id_required: kb_id is required") {
|
||||
t.Errorf("text fallback missing code:message; got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// contentText extracts the Text field from an mcpsdk.Content value.
|
||||
// Lives in the test file to avoid leaking helpers into production.
|
||||
func contentText(t *testing.T, c any) string {
|
||||
t.Helper()
|
||||
// Direct assertion to the concrete type used by toolErrorResult.
|
||||
if tc, ok := c.(*mcpsdk.TextContent); ok {
|
||||
return tc.Text
|
||||
}
|
||||
// Fallback: some go-sdk versions may expose a GetText() method.
|
||||
if tc, ok := c.(interface{ GetText() string }); ok {
|
||||
return tc.GetText()
|
||||
}
|
||||
t.Fatalf("cannot extract text from Content of type %T", c)
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestToolErrorResult_IsErrorFlagSet verifies IsError is always true.
|
||||
func TestToolErrorResult_IsErrorFlagSet(t *testing.T) {
|
||||
res := toolErrorResult(cmdutil.NewError(cmdutil.CodeInputMissingFlag, "doc_id is required"))
|
||||
if !res.IsError {
|
||||
t.Error("expected IsError=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolErrorResult_ContentPresent verifies Content slice is non-empty.
|
||||
func TestToolErrorResult_ContentPresent(t *testing.T) {
|
||||
res := toolErrorResult(cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "page_size must be in 1..1000"))
|
||||
if len(res.Content) == 0 {
|
||||
t.Error("expected at least one Content item")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolErrorResult_NetworkError verifies a network error code round-trips
|
||||
// through toolErrorResult with the correct type in StructuredContent.
|
||||
func TestToolErrorResult_NetworkError(t *testing.T) {
|
||||
err := cmdutil.NewError(cmdutil.CodeNetworkError, "dial failed")
|
||||
res := toolErrorResult(err)
|
||||
if !res.IsError {
|
||||
t.Error("expected IsError=true")
|
||||
}
|
||||
sc, ok := res.StructuredContent.(*output.ErrDetail)
|
||||
if !ok {
|
||||
t.Fatalf("StructuredContent has unexpected type %T", res.StructuredContent)
|
||||
}
|
||||
if sc.Type != "network.error" {
|
||||
t.Errorf("type: got %v want network.error", sc.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused-import warning if sdk isn't used in this file.
|
||||
var _ = sdk.KnowledgeBase{}
|
||||
@@ -0,0 +1,737 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeSvc implements every narrow service interface ServiceClient embeds.
|
||||
// Each method records the last call args; per-test setup populates the
|
||||
// return values it wants to assert against.
|
||||
type fakeSvc struct {
|
||||
listKBs []sdk.KnowledgeBase
|
||||
listKBsErr error
|
||||
getKB *sdk.KnowledgeBase
|
||||
getKBErr error
|
||||
listDocs []sdk.Knowledge
|
||||
listDocsTotal int64
|
||||
listDocsErr error
|
||||
getDoc *sdk.Knowledge
|
||||
getDocErr error
|
||||
openDocName string
|
||||
openDocBody io.ReadCloser
|
||||
openDocErr error
|
||||
hybridResults []*sdk.SearchResult
|
||||
hybridErr error
|
||||
createSess *sdk.Session
|
||||
createSessErr error
|
||||
kbStreamEvents []*sdk.StreamResponse
|
||||
kbStreamErr error
|
||||
agents []sdk.Agent
|
||||
agentsErr error
|
||||
agent *sdk.Agent
|
||||
agentErr error
|
||||
agentEvents []*sdk.AgentStreamResponse
|
||||
agentStreamErr error
|
||||
chunks []sdk.Chunk
|
||||
chunksTotal int64
|
||||
chunksErr error
|
||||
// Captured args:
|
||||
calls struct {
|
||||
listKBs int
|
||||
kbViewID string
|
||||
docListKBID string
|
||||
docListFilter sdk.KnowledgeListFilter
|
||||
docViewID string
|
||||
openDocID string
|
||||
hybridKBID string
|
||||
hybridParams *sdk.SearchParams
|
||||
createSessReq *sdk.CreateSessionRequest
|
||||
kbQAReq *sdk.KnowledgeQARequest
|
||||
kbQASess string
|
||||
agentListN int
|
||||
agentViewID string
|
||||
agentReq *sdk.AgentQARequest
|
||||
agentSess string
|
||||
chunkDocID string
|
||||
chunkPage int
|
||||
chunkPageSize int
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSvc) ListKnowledgeBases(_ context.Context) ([]sdk.KnowledgeBase, error) {
|
||||
f.calls.listKBs++
|
||||
return f.listKBs, f.listKBsErr
|
||||
}
|
||||
func (f *fakeSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
|
||||
f.calls.kbViewID = id
|
||||
return f.getKB, f.getKBErr
|
||||
}
|
||||
func (f *fakeSvc) ListKnowledgeWithFilter(_ context.Context, kbID string, _, _ int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) {
|
||||
f.calls.docListKBID = kbID
|
||||
f.calls.docListFilter = filter
|
||||
return f.listDocs, f.listDocsTotal, f.listDocsErr
|
||||
}
|
||||
func (f *fakeSvc) GetKnowledge(_ context.Context, id string) (*sdk.Knowledge, error) {
|
||||
f.calls.docViewID = id
|
||||
return f.getDoc, f.getDocErr
|
||||
}
|
||||
func (f *fakeSvc) OpenKnowledgeFile(_ context.Context, id string) (string, io.ReadCloser, error) {
|
||||
f.calls.openDocID = id
|
||||
return f.openDocName, f.openDocBody, f.openDocErr
|
||||
}
|
||||
func (f *fakeSvc) HybridSearch(
|
||||
_ context.Context, kbID string, p *sdk.SearchParams, _ ...sdk.ResourceURLOptions,
|
||||
) ([]*sdk.SearchResult, error) {
|
||||
f.calls.hybridKBID, f.calls.hybridParams = kbID, p
|
||||
return f.hybridResults, f.hybridErr
|
||||
}
|
||||
func (f *fakeSvc) CreateSession(_ context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error) {
|
||||
f.calls.createSessReq = req
|
||||
if f.createSess == nil && f.createSessErr == nil {
|
||||
return &sdk.Session{ID: "sess_auto"}, nil
|
||||
}
|
||||
return f.createSess, f.createSessErr
|
||||
}
|
||||
func (f *fakeSvc) KnowledgeQAStream(_ context.Context, sess string, req *sdk.KnowledgeQARequest, cb func(*sdk.StreamResponse) error, opts ...sdk.ResourceURLOptions) error {
|
||||
f.calls.kbQASess, f.calls.kbQAReq = sess, req
|
||||
for _, e := range f.kbStreamEvents {
|
||||
if err := cb(e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return f.kbStreamErr
|
||||
}
|
||||
func (f *fakeSvc) ListAgents(_ context.Context) ([]sdk.Agent, error) {
|
||||
f.calls.agentListN++
|
||||
return f.agents, f.agentsErr
|
||||
}
|
||||
func (f *fakeSvc) GetAgent(_ context.Context, id string) (*sdk.Agent, error) {
|
||||
f.calls.agentViewID = id
|
||||
return f.agent, f.agentErr
|
||||
}
|
||||
func (f *fakeSvc) AgentQAStreamWithRequest(_ context.Context, sess string, req *sdk.AgentQARequest, cb sdk.AgentEventCallback, opts ...sdk.ResourceURLOptions) error {
|
||||
f.calls.agentSess, f.calls.agentReq = sess, req
|
||||
for _, e := range f.agentEvents {
|
||||
if err := cb(e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return f.agentStreamErr
|
||||
}
|
||||
func (f *fakeSvc) ListKnowledgeChunks(_ context.Context, docID string, page, pageSize int, _ ...string) ([]sdk.Chunk, int64, error) {
|
||||
f.calls.chunkDocID = docID
|
||||
f.calls.chunkPage = page
|
||||
f.calls.chunkPageSize = pageSize
|
||||
return f.chunks, f.chunksTotal, f.chunksErr
|
||||
}
|
||||
|
||||
// newTestServer wires svc to an in-process MCP server and returns a
|
||||
// connected client session ready to CallTool against it.
|
||||
func newTestServer(t *testing.T, svc ServiceClient) (*mcpsdk.ClientSession, context.CancelFunc) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "weknora-test", Version: "v0.0.0-test"}, nil)
|
||||
registerTools(server, svc)
|
||||
|
||||
st, ct := mcpsdk.NewInMemoryTransports()
|
||||
serverSession, err := server.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatalf("server.Connect: %v", err)
|
||||
}
|
||||
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test-client", Version: "v0.0.0"}, nil)
|
||||
clientSession, err := client.Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
_ = serverSession.Close()
|
||||
cancel()
|
||||
t.Fatalf("client.Connect: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = clientSession.Close()
|
||||
_ = serverSession.Close()
|
||||
cancel()
|
||||
})
|
||||
return clientSession, cancel
|
||||
}
|
||||
|
||||
// callTool invokes name with args and returns the parsed structured output.
|
||||
func callTool(t *testing.T, c *mcpsdk.ClientSession, name string, args any, out any) *mcpsdk.CallToolResult {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: args})
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool(%s): %v", name, err)
|
||||
}
|
||||
if res.IsError {
|
||||
if len(res.Content) > 0 {
|
||||
t.Fatalf("tool %s returned error: %+v", name, res.Content)
|
||||
}
|
||||
t.Fatalf("tool %s returned error (no content)", name)
|
||||
}
|
||||
if out != nil && res.StructuredContent != nil {
|
||||
b, _ := json.Marshal(res.StructuredContent)
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
t.Fatalf("decode %s output: %v\nraw=%s", name, err, b)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestTool_ListsRegistered(t *testing.T) {
|
||||
c, _ := newTestServer(t, &fakeSvc{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTools: %v", err)
|
||||
}
|
||||
want := []string{"kb_list", "kb_view", "doc_list", "doc_view", "doc_download", "search_chunks", "chat", "agent_list", "session_ask", "chunk_list"}
|
||||
got := map[string]bool{}
|
||||
for _, tool := range res.Tools {
|
||||
got[tool.Name] = true
|
||||
}
|
||||
for _, name := range want {
|
||||
if !got[name] {
|
||||
t.Errorf("missing tool %q in ListTools response", name)
|
||||
}
|
||||
}
|
||||
if len(res.Tools) != len(want) {
|
||||
t.Errorf("registered %d tools, want exactly %d (no scope creep)", len(res.Tools), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_SessionAsk_NotAgentInvoke asserts the MCP rename landed: the
|
||||
// registered set must contain "session_ask" and must NOT contain the
|
||||
// stale "agent_invoke" name (clean break, no deprecation alias).
|
||||
func TestTool_SessionAsk_NotAgentInvoke(t *testing.T) {
|
||||
c, _ := newTestServer(t, &fakeSvc{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTools: %v", err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tool := range res.Tools {
|
||||
names[tool.Name] = true
|
||||
}
|
||||
if !names["session_ask"] {
|
||||
t.Error("expected tool 'session_ask' to be registered")
|
||||
}
|
||||
if names["agent_invoke"] {
|
||||
t.Error("stale tool 'agent_invoke' must NOT be registered (clean break, no alias)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_KBList(t *testing.T) {
|
||||
svc := &fakeSvc{listKBs: []sdk.KnowledgeBase{{ID: "kb1", Name: "Marketing"}}}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out kbListOutput
|
||||
callTool(t, c, "kb_list", map[string]any{}, &out)
|
||||
if len(out.Items) != 1 || out.Items[0].ID != "kb1" {
|
||||
t.Errorf("got %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_KBView_RequiresKBID(t *testing.T) {
|
||||
c, _ := newTestServer(t, &fakeSvc{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.CallTool(ctx, &mcpsdk.CallToolParams{Name: "kb_view", Arguments: map[string]any{}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if !res.IsError {
|
||||
t.Fatal("expected IsError=true on missing kb_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_KBView(t *testing.T) {
|
||||
svc := &fakeSvc{getKB: &sdk.KnowledgeBase{ID: "kb_x", Name: "Eng"}}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out sdk.KnowledgeBase
|
||||
callTool(t, c, "kb_view", map[string]any{"kb_id": "kb_x"}, &out)
|
||||
if out.ID != "kb_x" || out.Name != "Eng" {
|
||||
t.Errorf("got %+v", out)
|
||||
}
|
||||
if svc.calls.kbViewID != "kb_x" {
|
||||
t.Errorf("kb_id not forwarded: %s", svc.calls.kbViewID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_DocList_DefaultPagination(t *testing.T) {
|
||||
svc := &fakeSvc{listDocs: []sdk.Knowledge{{ID: "k1"}}, listDocsTotal: 1}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out docListOutput
|
||||
callTool(t, c, "doc_list", map[string]any{"kb_id": "kb_x"}, &out)
|
||||
if out.Page != 1 || out.PageSize != 20 {
|
||||
t.Errorf("default pagination not applied: %+v", out)
|
||||
}
|
||||
if svc.calls.docListKBID != "kb_x" {
|
||||
t.Errorf("kb_id not forwarded: %s", svc.calls.docListKBID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_DocList_StatusFilter_Forwarded(t *testing.T) {
|
||||
svc := &fakeSvc{}
|
||||
c, _ := newTestServer(t, svc)
|
||||
callTool(t, c, "doc_list", map[string]any{"kb_id": "kb_x", "status": "failed"}, nil)
|
||||
if svc.calls.docListFilter.ParseStatus != "failed" {
|
||||
t.Errorf("status not forwarded as filter.ParseStatus: %+v", svc.calls.docListFilter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_DocList_PassesFilterFields drives every C11 filter field at once
|
||||
// and asserts they all land on filter struct (AND-combined server-side).
|
||||
func TestTool_DocList_PassesFilterFields(t *testing.T) {
|
||||
svc := &fakeSvc{}
|
||||
c, _ := newTestServer(t, svc)
|
||||
args := map[string]any{
|
||||
"kb_id": "kb_x",
|
||||
"status": "completed",
|
||||
"keyword": "spec",
|
||||
"file_type": "pdf",
|
||||
"source": "api",
|
||||
"tag_id": "tag_42",
|
||||
"start_time": "2026-01-01T00:00:00Z",
|
||||
"end_time": "2026-12-31T23:59:59Z",
|
||||
}
|
||||
callTool(t, c, "doc_list", args, nil)
|
||||
f := svc.calls.docListFilter
|
||||
assert.Equal(t, "completed", f.ParseStatus)
|
||||
assert.Equal(t, "spec", f.Keyword)
|
||||
assert.Equal(t, "pdf", f.FileType)
|
||||
assert.Equal(t, "api", f.Source)
|
||||
assert.Equal(t, "tag_42", f.TagID)
|
||||
assert.False(t, f.StartTime.IsZero(), "start_time RFC3339 must populate filter.StartTime")
|
||||
assert.False(t, f.EndTime.IsZero(), "end_time RFC3339 must populate filter.EndTime")
|
||||
}
|
||||
|
||||
// TestTool_DocList_InvalidStartTime asserts malformed RFC3339 is rejected
|
||||
// at the handler boundary (before the SDK is called).
|
||||
func TestTool_DocList_InvalidStartTime(t *testing.T) {
|
||||
c, _ := newTestServer(t, &fakeSvc{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||
Name: "doc_list",
|
||||
Arguments: map[string]any{"kb_id": "kb_x", "start_time": "tomorrow"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.IsError, "expected IsError=true on malformed RFC3339 start_time")
|
||||
}
|
||||
|
||||
// TestTool_DocList_InvalidEndTime mirrors the start_time guard for end_time.
|
||||
func TestTool_DocList_InvalidEndTime(t *testing.T) {
|
||||
c, _ := newTestServer(t, &fakeSvc{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||
Name: "doc_list",
|
||||
Arguments: map[string]any{"kb_id": "kb_x", "end_time": "2026-05-01"}, // date-only, not RFC3339
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.IsError, "expected IsError=true on malformed RFC3339 end_time")
|
||||
}
|
||||
|
||||
func TestTool_DocView(t *testing.T) {
|
||||
svc := &fakeSvc{getDoc: &sdk.Knowledge{ID: "k1", FileName: "a.pdf"}}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out sdk.Knowledge
|
||||
callTool(t, c, "doc_view", map[string]any{"doc_id": "k1"}, &out)
|
||||
if out.ID != "k1" {
|
||||
t.Errorf("got %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_DocDownload_Text(t *testing.T) {
|
||||
svc := &fakeSvc{
|
||||
openDocName: "notes.txt",
|
||||
openDocBody: io.NopCloser(strings.NewReader("hello world")),
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out docDownloadOutput
|
||||
callTool(t, c, "doc_download", map[string]any{"doc_id": "k1"}, &out)
|
||||
if out.Content != "hello world" {
|
||||
t.Errorf("content = %q", out.Content)
|
||||
}
|
||||
if out.IsBase64 {
|
||||
t.Error("text content should not be base64-encoded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_DocDownload_BinaryBase64(t *testing.T) {
|
||||
// First 512 bytes contain a NUL → encodeDownload returns base64.
|
||||
bin := []byte{0x00, 0x01, 0x02, 0x03}
|
||||
svc := &fakeSvc{
|
||||
openDocName: "blob.bin",
|
||||
openDocBody: io.NopCloser(strings.NewReader(string(bin))),
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out docDownloadOutput
|
||||
callTool(t, c, "doc_download", map[string]any{"doc_id": "k1"}, &out)
|
||||
if !out.IsBase64 {
|
||||
t.Errorf("binary should be base64; got is_base64=%v content=%q", out.IsBase64, out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_SearchChunks(t *testing.T) {
|
||||
svc := &fakeSvc{hybridResults: []*sdk.SearchResult{{KnowledgeID: "k1", Score: 0.9}}}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out searchChunksOutput
|
||||
callTool(t, c, "search_chunks", map[string]any{"kb_id": "kb_x", "query": "what is RAG"}, &out)
|
||||
if len(out.Results) != 1 || out.Results[0].KnowledgeID != "k1" {
|
||||
t.Errorf("got %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_SearchChunks_LimitCap(t *testing.T) {
|
||||
// 5 results, limit 3 → 3 returned.
|
||||
svc := &fakeSvc{}
|
||||
for i := 0; i < 5; i++ {
|
||||
svc.hybridResults = append(svc.hybridResults, &sdk.SearchResult{KnowledgeID: "k", Score: float64(i)})
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out searchChunksOutput
|
||||
callTool(t, c, "search_chunks", map[string]any{"kb_id": "kb_x", "query": "x", "limit": 3}, &out)
|
||||
if len(out.Results) != 3 {
|
||||
t.Errorf("limit not honored: got %d, want 3", len(out.Results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_SearchChunks_PassesMatchCountFromLimit is a regression guard for
|
||||
// the v0.5 audit bug: the search_chunks dispatch built SearchParams without
|
||||
// setting MatchCount, so the server fell back to its default cap and the
|
||||
// client-side trim (results[:limit]) was a no-op when limit > server default.
|
||||
// Verifies the limit arg is threaded into SearchParams.MatchCount.
|
||||
func TestTool_SearchChunks_PassesMatchCountFromLimit(t *testing.T) {
|
||||
svc := &fakeSvc{}
|
||||
c, _ := newTestServer(t, svc)
|
||||
callTool(t, c, "search_chunks", map[string]any{"kb_id": "kb_x", "query": "test", "limit": 50}, nil)
|
||||
require.NotNil(t, svc.calls.hybridParams, "HybridSearch must be called with non-nil SearchParams")
|
||||
assert.Equal(t, 50, svc.calls.hybridParams.MatchCount, "MCP search_chunks must thread limit into SearchParams.MatchCount")
|
||||
}
|
||||
|
||||
func TestTool_Chat_DefaultReturnsAnswerEventsOnly(t *testing.T) {
|
||||
svc := &fakeSvc{
|
||||
kbStreamEvents: []*sdk.StreamResponse{
|
||||
{Content: "Hello "},
|
||||
{Content: "world."},
|
||||
{KnowledgeReferences: []*sdk.SearchResult{{
|
||||
ID: "c1",
|
||||
KnowledgeID: "k1",
|
||||
ParentChunkID: "p1",
|
||||
Content: "bulky passage",
|
||||
}}},
|
||||
{ResponseType: sdk.ResponseTypeComplete},
|
||||
},
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out chatOutput
|
||||
callTool(t, c, "chat", map[string]any{"kb_id": "kb_x", "query": "ping"}, &out)
|
||||
if len(out.Events) != 2 || out.Events[0].Content != "Hello " || out.Events[1].Content != "world." {
|
||||
t.Errorf("answer events=%+v", out.Events)
|
||||
}
|
||||
for _, event := range out.Events {
|
||||
if event.ResponseType != "answer" || len(event.KnowledgeReferences) != 0 {
|
||||
t.Errorf("default output leaked non-answer data: %+v", event)
|
||||
}
|
||||
}
|
||||
if out.SessionID != "sess_auto" {
|
||||
t.Errorf("session_id = %q, want sess_auto", out.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCP_ChatVerbosePreservesThinking(t *testing.T) {
|
||||
svc := &fakeSvc{
|
||||
kbStreamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeThinking, Content: "let me reason..."},
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "final answer"},
|
||||
{ResponseType: sdk.ResponseTypeComplete},
|
||||
},
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out chatOutput
|
||||
callTool(t, c, "chat", map[string]any{"kb_id": "kb_x", "query": "deep question", "verbose": true}, &out)
|
||||
want := []string{"thinking", "answer", "complete"}
|
||||
if len(out.Events) != len(want) {
|
||||
t.Fatalf("events=%+v", out.Events)
|
||||
}
|
||||
for i, responseType := range want {
|
||||
if out.Events[i].ResponseType != responseType {
|
||||
t.Errorf("events[%d]=%q, want %q", i, out.Events[i].ResponseType, responseType)
|
||||
}
|
||||
}
|
||||
if out.KBID != "kb_x" {
|
||||
t.Errorf("kb_id = %q, want %q", out.KBID, "kb_x")
|
||||
}
|
||||
if out.Query != "deep question" {
|
||||
t.Errorf("query = %q, want %q", out.Query, "deep question")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCP_SessionAskVerboseAndReferenceReturnsBothDetailClasses(t *testing.T) {
|
||||
svc := &fakeSvc{
|
||||
agentEvents: []*sdk.AgentStreamResponse{
|
||||
{ResponseType: sdk.AgentResponseTypeThinking, Content: "agent thinks"},
|
||||
{ResponseType: sdk.AgentResponseTypeToolCall, ID: "tc1", Content: "knowledge_search"},
|
||||
{ResponseType: sdk.AgentResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{{
|
||||
ID: "c1",
|
||||
ParentChunkID: "p1",
|
||||
Content: "bulky passage",
|
||||
}}},
|
||||
{ResponseType: sdk.AgentResponseTypeAnswer, Content: "agent answer"},
|
||||
{ResponseType: sdk.AgentResponseTypeComplete, Done: true},
|
||||
},
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out sessionAskOutput
|
||||
callTool(t, c, "session_ask", map[string]any{"agent_id": "ag1", "query": "tool question", "verbose": true, "reference": true}, &out)
|
||||
want := []string{"thinking", "tool_call", "references", "answer", "complete"}
|
||||
if len(out.Events) != len(want) {
|
||||
t.Fatalf("events=%+v", out.Events)
|
||||
}
|
||||
for i, responseType := range want {
|
||||
if out.Events[i].ResponseType != responseType {
|
||||
t.Errorf("events[%d]=%q, want %q", i, out.Events[i].ResponseType, responseType)
|
||||
}
|
||||
}
|
||||
refs := out.Events[2].KnowledgeReferences
|
||||
if len(refs) != 1 || refs[0].ChunkID != "c1" || refs[0].ParentChunkID != "p1" {
|
||||
t.Errorf("reference indexes=%+v", refs)
|
||||
}
|
||||
if out.Query != "tool question" {
|
||||
t.Errorf("query = %q, want %q", out.Query, "tool question")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_Chat_ExistingSessionSkipsCreate(t *testing.T) {
|
||||
svc := &fakeSvc{
|
||||
kbStreamEvents: []*sdk.StreamResponse{{ResponseType: sdk.ResponseTypeComplete}},
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
callTool(t, c, "chat", map[string]any{"kb_id": "kb_x", "query": "x", "session_id": "sess_existing"}, nil)
|
||||
if svc.calls.createSessReq != nil {
|
||||
t.Error("CreateSession should not fire when session_id is supplied")
|
||||
}
|
||||
if svc.calls.kbQASess != "sess_existing" {
|
||||
t.Errorf("session id not forwarded to QA stream: %s", svc.calls.kbQASess)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_AgentList(t *testing.T) {
|
||||
svc := &fakeSvc{agents: []sdk.Agent{{ID: "ag1", Name: "Research"}}}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out agentListOutput
|
||||
callTool(t, c, "agent_list", map[string]any{}, &out)
|
||||
if len(out.Items) != 1 || out.Items[0].ID != "ag1" {
|
||||
t.Errorf("got %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_SessionAsk(t *testing.T) {
|
||||
svc := &fakeSvc{
|
||||
agentEvents: []*sdk.AgentStreamResponse{
|
||||
{ResponseType: sdk.AgentResponseTypeAnswer, Content: "result"},
|
||||
{ResponseType: sdk.AgentResponseTypeToolCall, ID: "c1", Content: "knowledge_search"},
|
||||
{ResponseType: sdk.AgentResponseTypeComplete, Done: true},
|
||||
},
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out sessionAskOutput
|
||||
callTool(t, c, "session_ask", map[string]any{"agent_id": "ag1", "query": "x"}, &out)
|
||||
if len(out.Events) != 1 || out.Events[0].ResponseType != "answer" || out.Events[0].Content != "result" {
|
||||
t.Errorf("default events=%+v", out.Events)
|
||||
}
|
||||
if out.AgentID != "ag1" {
|
||||
t.Errorf("agent_id = %q", out.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_SessionAsk_StreamAbort(t *testing.T) {
|
||||
svc := &fakeSvc{
|
||||
agentEvents: []*sdk.AgentStreamResponse{{ResponseType: sdk.AgentResponseTypeAnswer, Content: "partial"}},
|
||||
agentStreamErr: errors.New("connection reset"),
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.CallTool(ctx, &mcpsdk.CallToolParams{Name: "session_ask", Arguments: map[string]any{"agent_id": "ag1", "query": "x"}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if !res.IsError {
|
||||
t.Fatal("expected IsError=true on mid-stream abort")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_Chat_StreamErrorIncludesSessionDetail(t *testing.T) {
|
||||
svc := &fakeSvc{
|
||||
kbStreamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "partial"},
|
||||
{ResponseType: sdk.ResponseTypeError, Content: "boom", Done: true},
|
||||
},
|
||||
kbStreamErr: sdk.NewSSEStreamError("boom"),
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.CallTool(ctx, &mcpsdk.CallToolParams{Name: "chat", Arguments: map[string]any{"kb_id": "kb_x", "query": "q"}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if !res.IsError {
|
||||
t.Fatal("expected IsError=true on terminal stream error")
|
||||
}
|
||||
b, err := json.Marshal(res.StructuredContent)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal structured content: %v", err)
|
||||
}
|
||||
var detail struct {
|
||||
Type string `json:"type"`
|
||||
Detail map[string]any `json:"detail"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &detail); err != nil {
|
||||
t.Fatalf("unmarshal error detail: %v", err)
|
||||
}
|
||||
if detail.Type != "server.error" {
|
||||
t.Errorf("type=%q, want server.error", detail.Type)
|
||||
}
|
||||
if detail.Detail["session_id"] != "sess_auto" {
|
||||
t.Errorf("detail=%v, want session_id sess_auto", detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_ChunkList_Happy(t *testing.T) {
|
||||
svc := &fakeSvc{
|
||||
chunks: []sdk.Chunk{{ID: "c1", ChunkIndex: 0, Content: "hello"}},
|
||||
chunksTotal: 1,
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out chunkListOutput
|
||||
callTool(t, c, "chunk_list", map[string]any{"doc_id": "doc_abc", "limit": 50}, &out)
|
||||
require.Len(t, out.Chunks, 1)
|
||||
assert.Equal(t, "c1", out.Chunks[0].ID)
|
||||
assert.Equal(t, "doc_abc", svc.calls.chunkDocID)
|
||||
assert.Equal(t, 1, svc.calls.chunkPage)
|
||||
assert.Equal(t, 50, svc.calls.chunkPageSize) // SDK page=1, pageSize=limit
|
||||
}
|
||||
|
||||
func TestTool_ChunkList_TruncatedAtLimit(t *testing.T) {
|
||||
svc := &fakeSvc{
|
||||
chunks: []sdk.Chunk{{ID: "c1"}},
|
||||
chunksTotal: 100, // more than limit
|
||||
}
|
||||
c, _ := newTestServer(t, svc)
|
||||
var out chunkListOutput
|
||||
callTool(t, c, "chunk_list", map[string]any{"doc_id": "d", "limit": 1}, &out)
|
||||
assert.True(t, out.TruncatedAtLimit)
|
||||
}
|
||||
|
||||
func TestTool_ChunkList_MissingDocID(t *testing.T) {
|
||||
c, _ := newTestServer(t, &fakeSvc{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.CallTool(ctx, &mcpsdk.CallToolParams{Name: "chunk_list", Arguments: map[string]any{"limit": 50}})
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.IsError, "expected IsError=true on missing doc_id")
|
||||
}
|
||||
|
||||
// TestTool_ChunkList_NonNumericLimit asserts the MCP framework rejects a
|
||||
// string-valued `limit`. The schema declares limit as integer (via the
|
||||
// chunkListInput struct tag), so non-numeric values fail validation
|
||||
// before the handler runs.
|
||||
func TestTool_ChunkList_NonNumericLimit(t *testing.T) {
|
||||
c, _ := newTestServer(t, &fakeSvc{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||
Name: "chunk_list",
|
||||
Arguments: map[string]any{"doc_id": "d", "limit": "50"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.IsError, "expected IsError=true when limit is a string")
|
||||
}
|
||||
|
||||
// derefBool is the test-side counterpart to bptr: ToolAnnotations uses
|
||||
// *bool for DestructiveHint/OpenWorldHint to distinguish "unset" from
|
||||
// "false", but assertions here treat unset as false (a nil pointer means
|
||||
// the field was omitted from the JSON wire envelope, which clients should
|
||||
// read as the documented default).
|
||||
func derefBool(p *bool) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// TestToolAnnotations_AllToolsHaveExpectedHints locks the per-tool hint
|
||||
// table. Each of the 10 registered tools must surface the exact
|
||||
// DestructiveHint / ReadOnlyHint / IdempotentHint / OpenWorldHint + Title
|
||||
// values shown below. This guards against silent drift during future
|
||||
// refactors (e.g. someone marking chat as readOnly, or an invoke tool as
|
||||
// closed-world).
|
||||
//
|
||||
// Note on plain-bool fields: ReadOnlyHint and IdempotentHint are bool
|
||||
// (not *bool) with `omitempty`. For invoke-class tools that explicitly set
|
||||
// them to false in the builder, the JSON envelope omits the field and the
|
||||
// client-side decode surfaces the zero value (false), which matches the
|
||||
// table.
|
||||
func TestToolAnnotations_AllToolsHaveExpectedHints(t *testing.T) {
|
||||
expected := map[string]struct {
|
||||
destructive bool
|
||||
readOnly bool
|
||||
idempotent bool
|
||||
openWorld bool
|
||||
title string
|
||||
}{
|
||||
"kb_list": {destructive: false, readOnly: true, idempotent: true, openWorld: false, title: "List Knowledge Bases"},
|
||||
"kb_view": {destructive: false, readOnly: true, idempotent: true, openWorld: false, title: "View Knowledge Base"},
|
||||
"doc_list": {destructive: false, readOnly: true, idempotent: true, openWorld: false, title: "List Documents"},
|
||||
"doc_view": {destructive: false, readOnly: true, idempotent: true, openWorld: false, title: "View Document"},
|
||||
"doc_download": {destructive: false, readOnly: true, idempotent: true, openWorld: false, title: "Download Document"},
|
||||
"search_chunks": {destructive: false, readOnly: true, idempotent: true, openWorld: false, title: "Search Knowledge Chunks"},
|
||||
"chat": {destructive: false, readOnly: false, idempotent: false, openWorld: true, title: "Chat with KB (Streaming RAG)"},
|
||||
"agent_list": {destructive: false, readOnly: true, idempotent: true, openWorld: false, title: "List Custom Agents"},
|
||||
"session_ask": {destructive: false, readOnly: false, idempotent: false, openWorld: true, title: "Ask a Custom Agent (session ask --agent)"},
|
||||
"chunk_list": {destructive: false, readOnly: true, idempotent: true, openWorld: false, title: "List Knowledge Chunks"},
|
||||
}
|
||||
|
||||
c, _ := newTestServer(t, &fakeSvc{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res, err := c.ListTools(ctx, nil)
|
||||
require.NoError(t, err, "ListTools must succeed")
|
||||
|
||||
got := map[string]*mcpsdk.Tool{}
|
||||
for _, tool := range res.Tools {
|
||||
got[tool.Name] = tool
|
||||
}
|
||||
|
||||
for name, want := range expected {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
tool, ok := got[name]
|
||||
require.True(t, ok, "tool %q not registered", name)
|
||||
require.NotNil(t, tool.Annotations, "tool %q must set Annotations", name)
|
||||
a := tool.Annotations
|
||||
assert.Equal(t, want.title, a.Title, "Title")
|
||||
assert.Equal(t, want.destructive, derefBool(a.DestructiveHint), "DestructiveHint")
|
||||
assert.Equal(t, want.readOnly, a.ReadOnlyHint, "ReadOnlyHint")
|
||||
assert.Equal(t, want.idempotent, a.IdempotentHint, "IdempotentHint")
|
||||
assert.Equal(t, want.openWorld, derefBool(a.OpenWorldHint), "OpenWorldHint")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package output
|
||||
|
||||
import "io"
|
||||
|
||||
// BatchItem is one per-id outcome in a batch operation envelope.
|
||||
type BatchItem struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *ErrDetail `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WriteBatchEnvelope writes a batch operation envelope.
|
||||
//
|
||||
// Wire shape: {ok, data:[BatchItem...], meta:{count, successes, failures}, profile?}.
|
||||
// Top-level ok = (failures == 0). Per-id ok reflects each item's outcome.
|
||||
// Even when all items fail, the response stays in success-envelope shape
|
||||
// (data array, not error envelope) so agents can iterate detail per id.
|
||||
// profile is the resolved profile name for the invocation; empty string omits the field.
|
||||
func WriteBatchEnvelope(w io.Writer, items []BatchItem, indent bool, profile string) error {
|
||||
if items == nil {
|
||||
items = []BatchItem{}
|
||||
}
|
||||
successes, failures := 0, 0
|
||||
for _, it := range items {
|
||||
if it.OK {
|
||||
successes++
|
||||
} else {
|
||||
failures++
|
||||
}
|
||||
}
|
||||
env := Envelope{
|
||||
OK: failures == 0,
|
||||
Status: batchStatus(successes, failures),
|
||||
Data: items,
|
||||
Meta: &Meta{
|
||||
Count: IntPtr(len(items)),
|
||||
Successes: &successes,
|
||||
Failures: &failures,
|
||||
},
|
||||
Profile: profile,
|
||||
}
|
||||
return writeJSON(w, env, indent)
|
||||
}
|
||||
|
||||
// batchStatus maps the (successes, failures) counts to the tri-state envelope
|
||||
// status: "success" when no failures, "error" when no successes, "partial"
|
||||
// when both occur.
|
||||
func batchStatus(successes, failures int) string {
|
||||
switch {
|
||||
case failures == 0:
|
||||
return "success"
|
||||
case successes == 0:
|
||||
return "error"
|
||||
default:
|
||||
return "partial"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package output_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWriteBatchEnvelope_AllSuccess(t *testing.T) {
|
||||
items := []output.BatchItem{
|
||||
{ID: "id1", OK: true, Result: map[string]string{"deleted_at": "2026-05-20T00:00:00Z"}},
|
||||
{ID: "id2", OK: true, Result: map[string]string{"deleted_at": "2026-05-20T00:00:00Z"}},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := output.WriteBatchEnvelope(&buf, items, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"ok":true`) {
|
||||
t.Errorf("expected ok:true; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"successes":2`) {
|
||||
t.Errorf("expected successes:2; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"count":2`) {
|
||||
t.Errorf("expected count:2; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteBatchEnvelope_PartialFailure(t *testing.T) {
|
||||
items := []output.BatchItem{
|
||||
{ID: "id1", OK: true, Result: map[string]string{"deleted_at": "2026-05-20T00:00:00Z"}},
|
||||
{ID: "id2", OK: false, Error: &output.ErrDetail{Type: "resource.not_found", Message: "id2 not found"}},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := output.WriteBatchEnvelope(&buf, items, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"ok":false`) {
|
||||
t.Errorf("expected ok:false (partial failure); got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"successes":1`) || !strings.Contains(got, `"failures":1`) {
|
||||
t.Errorf("expected counts; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"type":"resource.not_found"`) {
|
||||
t.Errorf("expected per-item error; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteBatchEnvelope_EmptyItems_EmitsEmptyArray(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := output.WriteBatchEnvelope(&buf, nil, false, ""); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if strings.Contains(got, `"data":null`) {
|
||||
t.Errorf("empty items must marshal to [], not null; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"data":[]`) {
|
||||
t.Errorf("expected data:[]; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"ok":true`) {
|
||||
t.Errorf("expected ok:true for empty batch; got %q", got)
|
||||
}
|
||||
// count:0 is omitted by omitempty; assert no non-zero count appears.
|
||||
if strings.Contains(got, `"count":`) {
|
||||
// If count key is present, it must not be a non-zero value.
|
||||
if !strings.Contains(got, `"count":0`) {
|
||||
t.Errorf("count key present but not zero; got %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteBatchEnvelope_StatusTriState verifies the additive tri-state
|
||||
// envelope.status: "success" (all ok), "partial" (mixed), "error" (all fail).
|
||||
func TestWriteBatchEnvelope_StatusTriState(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
items []output.BatchItem
|
||||
status string
|
||||
}{
|
||||
{
|
||||
name: "all_success",
|
||||
items: []output.BatchItem{
|
||||
{ID: "a", OK: true}, {ID: "b", OK: true},
|
||||
},
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
name: "partial",
|
||||
items: []output.BatchItem{
|
||||
{ID: "a", OK: true},
|
||||
{ID: "b", OK: false, Error: &output.ErrDetail{Type: "resource.not_found", Message: "missing"}},
|
||||
},
|
||||
status: "partial",
|
||||
},
|
||||
{
|
||||
name: "all_fail",
|
||||
items: []output.BatchItem{
|
||||
{ID: "a", OK: false, Error: &output.ErrDetail{Type: "auth.forbidden", Message: "no perm"}},
|
||||
{ID: "b", OK: false, Error: &output.ErrDetail{Type: "resource.not_found", Message: "missing"}},
|
||||
},
|
||||
status: "error",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := output.WriteBatchEnvelope(&buf, tc.items, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
want := `"status":"` + tc.status + `"`
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("expected %s; got %q", want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteBatchEnvelope_AllFail(t *testing.T) {
|
||||
items := []output.BatchItem{
|
||||
{ID: "id1", OK: false, Error: &output.ErrDetail{Type: "auth.forbidden", Message: "no perm"}},
|
||||
{ID: "id2", OK: false, Error: &output.ErrDetail{Type: "resource.not_found", Message: "missing"}},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := output.WriteBatchEnvelope(&buf, items, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"ok":false`) {
|
||||
t.Errorf("expected ok:false; got %q", got)
|
||||
}
|
||||
// All-fail still uses the batch envelope shape; it does not degrade
|
||||
// to ErrorEnvelope.
|
||||
// An ErrorEnvelope has a top-level "error" key alongside "ok"; a batch
|
||||
// envelope has a top-level "data" array. Check that "data":[ is present.
|
||||
if !strings.Contains(got, `"data":[`) {
|
||||
t.Errorf("batch all-fail should still use batch shape (data array), not error envelope; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"failures":2`) {
|
||||
t.Errorf("expected failures:2; got %q", got)
|
||||
}
|
||||
// H3b invariant: successes:0 must appear even when all items failed.
|
||||
// (*int semantics: nil is omitted; &0 is serialized as 0.)
|
||||
if !strings.Contains(got, `"successes":0`) {
|
||||
t.Errorf("all-fail batch must emit successes:0 (not omit it); invariant successes+failures==count would be broken; got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package output defines the symmetric envelope wire contract:
|
||||
// success envelopes on stdout (Envelope) and error envelopes on
|
||||
// stderr (ErrorEnvelope), plus NDJSON stream helpers.
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Envelope is the success-path stdout envelope. See AGENTS.md
|
||||
// "Stdout (success path)" for the full wire contract.
|
||||
type Envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
// Status is the batch tri-state outcome: "success" (all items ok),
|
||||
// "partial" (some ok, some failed), or "error" (all failed). Set only by
|
||||
// batch commands; omitted on ordinary single-result envelopes. `ok` stays
|
||||
// authoritative — Status is an additive convenience for agents triaging
|
||||
// multi-target results.
|
||||
Status string `json:"status,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorEnvelope is the error-path stderr envelope. See AGENTS.md
|
||||
// "Stderr (error path)" for the full wire contract.
|
||||
type ErrorEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Error *ErrDetail `json:"error"`
|
||||
}
|
||||
|
||||
// Meta carries optional metadata in success envelopes.
|
||||
type Meta struct {
|
||||
// Count and TotalCount are *int so zero is serialized when explicitly set
|
||||
// by list commands (omitempty on *int omits only nil, not zero). This keeps
|
||||
// the agent contract stable: an empty list still emits count/total_count as 0
|
||||
// instead of dropping the key. Non-list / dry-run metas leave them nil so they
|
||||
// are omitted. Mirrors the Successes/Failures pointer pattern below.
|
||||
Count *int `json:"count,omitempty"`
|
||||
HasMore bool `json:"has_more,omitempty"`
|
||||
TotalCount *int `json:"total_count,omitempty"`
|
||||
// Successes and Failures are *int so zero is serialized when explicitly set
|
||||
// by the batch path (omitempty on *int omits only nil, not zero).
|
||||
// Non-batch commands leave these nil so they are omitted from the envelope.
|
||||
Successes *int `json:"successes,omitempty"` // batch ops
|
||||
Failures *int `json:"failures,omitempty"` // batch ops
|
||||
// Hint is an optional actionable note on a SUCCESS envelope — e.g. an
|
||||
// empty search explaining the KB may be unindexed, or a freshly-created
|
||||
// draft document pointing at `doc reparse`. Distinct from error.hint;
|
||||
// omitted when empty so it never adds noise to normal results.
|
||||
Hint string `json:"hint,omitempty"`
|
||||
// Dry-run preview fields. Populated by EmitDryRun (cmdutil/dryrun.go)
|
||||
// when --dry-run is set on a mutation command; omitted otherwise.
|
||||
DryRun bool `json:"dry_run,omitempty"` // true when --dry-run; omitted otherwise
|
||||
Plan map[string]any `json:"plan,omitempty"` // would-call shape; open map (action required, other fields command-specific)
|
||||
}
|
||||
|
||||
// ErrDetail describes a structured error. Embedded in ErrorEnvelope.Error
|
||||
// and also surfaced in batch envelope per-item failures.
|
||||
type ErrDetail struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
// ExitCode is the process exit code this error maps to, embedded so an
|
||||
// agent can branch on a single JSON read without observing $?. Needed
|
||||
// because one type (input.invalid_argument) spans exit 2 (parse) and
|
||||
// exit 5 (typed value) — exit_code disambiguates them.
|
||||
ExitCode int `json:"exit_code,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
// RetryArgv is a directly-executable argv array (e.g.
|
||||
// ["weknora","auth","login"]) so an agent can exec it without
|
||||
// shell-splitting or quote-handling. Distinct from the prose Hint.
|
||||
RetryArgv []string `json:"retry_argv,omitempty"`
|
||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||
// Retryable indicates whether re-running the SAME command may succeed:
|
||||
// true for transient failures (timeouts, rate limits, transport), false
|
||||
// for deterministic ones (auth, bad input, not-found), omitted (nil) when
|
||||
// genuinely unknown.
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
Risk *RiskDetail `json:"risk,omitempty"`
|
||||
Detail any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// RiskDetail tags high-risk writes for the agent protocol. Surfaces in
|
||||
// error.risk on confirmation_required errors.
|
||||
// Level: "write" (reversible mutations — update) or "destructive" (delete);
|
||||
// the "read" slot is reserved.
|
||||
type RiskDetail struct {
|
||||
Level string `json:"level"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// NewEnvelope assembles a success Envelope with the given data + optional
|
||||
// meta + profile. Single source of construction so callers that need the
|
||||
// envelope value (e.g. jq filtering) stay in sync with WriteEnvelope when
|
||||
// fields are added.
|
||||
func NewEnvelope(data any, meta *Meta, profile string) Envelope {
|
||||
return Envelope{
|
||||
OK: true,
|
||||
Data: data,
|
||||
Meta: meta,
|
||||
Profile: profile,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteEnvelope writes a success envelope to w. Caller sets data + optional meta.
|
||||
//
|
||||
// When profile is non-empty, the envelope includes a "profile" field.
|
||||
// indent: if true, output is multi-line (TTY mode); else compact (pipe mode).
|
||||
func WriteEnvelope(w io.Writer, data any, meta *Meta, indent bool, profile string) error {
|
||||
return writeJSON(w, NewEnvelope(data, meta, profile), indent)
|
||||
}
|
||||
|
||||
// WriteErrorEnvelope writes an error envelope to w (typically stderr).
|
||||
func WriteErrorEnvelope(w io.Writer, err *ErrDetail, indent bool) error {
|
||||
env := ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: err,
|
||||
}
|
||||
return writeJSON(w, env, indent)
|
||||
}
|
||||
|
||||
// IntPtr returns a pointer to i. Used by list commands to set Meta.Count /
|
||||
// Meta.TotalCount so that a zero count still serializes (omitempty on *int
|
||||
// omits only nil). Mirrors the Successes/Failures pointer pattern.
|
||||
func IntPtr(i int) *int { return &i }
|
||||
|
||||
func writeJSON(w io.Writer, v any, indent bool) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
if indent {
|
||||
enc.SetIndent("", " ")
|
||||
}
|
||||
return enc.Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package output_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWriteEnvelope_SuccessWithData(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
data := map[string]string{"id": "kb_x"}
|
||||
if err := output.WriteEnvelope(&buf, data, nil, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"ok":true`) {
|
||||
t.Errorf("missing ok:true; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"data":{"id":"kb_x"}`) {
|
||||
t.Errorf("missing data; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelope_OmitDataWhenNil(t *testing.T) {
|
||||
// Mutation with no payload: the data field should be omitted (omitempty).
|
||||
var buf bytes.Buffer
|
||||
if err := output.WriteEnvelope(&buf, nil, nil, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if strings.Contains(got, `"data"`) {
|
||||
t.Errorf("data field should be omitted when nil; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"ok":true`) {
|
||||
t.Errorf("missing ok:true; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelope_WithMeta(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
meta := &output.Meta{Count: output.IntPtr(2), HasMore: false}
|
||||
if err := output.WriteEnvelope(&buf, []string{"a", "b"}, meta, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"meta":{"count":2}`) {
|
||||
// has_more:false should be omitted by omitempty when false
|
||||
t.Errorf("meta unexpected shape; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteEnvelope_ZeroCountSerializes pins the *int fix: a list command that
|
||||
// sets Count to 0 (empty result) must still emit "count":0, while a meta that
|
||||
// leaves Count nil (non-list / dry-run) must omit the key entirely. This is the
|
||||
// agent-contract guarantee that omitempty on a plain int silently broke.
|
||||
func TestWriteEnvelope_ZeroCountSerializes(t *testing.T) {
|
||||
var bufZero bytes.Buffer
|
||||
zero := &output.Meta{Count: output.IntPtr(0)}
|
||||
if err := output.WriteEnvelope(&bufZero, []string{}, zero, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotZero := bufZero.String(); !strings.Contains(gotZero, `"count":0`) {
|
||||
t.Errorf("explicit zero count must serialize; got %q", gotZero)
|
||||
}
|
||||
|
||||
var bufNil bytes.Buffer
|
||||
if err := output.WriteEnvelope(&bufNil, []string{}, &output.Meta{}, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotNil := bufNil.String(); strings.Contains(gotNil, "count") {
|
||||
t.Errorf("nil count must be omitted; got %q", gotNil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorEnvelope_FullShape(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
errDetail := &output.ErrDetail{
|
||||
Type: "input.confirmation_required",
|
||||
Message: "kb delete kb_x requires confirmation",
|
||||
Hint: "re-run with -y/--yes",
|
||||
RetryArgv: []string{"weknora", "kb", "delete", "kb_x", "-y"},
|
||||
Risk: &output.RiskDetail{
|
||||
Level: "destructive",
|
||||
Action: "kb.delete",
|
||||
},
|
||||
}
|
||||
if err := output.WriteErrorEnvelope(&buf, errDetail, false); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, `"ok":false`) {
|
||||
t.Errorf("missing ok:false; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"type":"input.confirmation_required"`) {
|
||||
t.Errorf("missing typed code; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"retry_argv":["weknora","kb","delete","kb_x","-y"]`) {
|
||||
t.Errorf("missing retry_argv; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"risk":{"level":"destructive","action":"kb.delete"}`) {
|
||||
t.Errorf("missing risk; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvelope_IndentedTTYMode(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := output.WriteEnvelope(&buf, map[string]string{"id": "x"}, nil, true, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "\n \"") {
|
||||
t.Errorf("expected indented multi-line output; got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
// InitEvent is the CLI-injected lifecycle event written at the head of a
|
||||
// streaming command's NDJSON output. Carries enough context for an agent to
|
||||
// thread follow-ups (session id, kb id, model, profile).
|
||||
//
|
||||
// Type field is always "init"; the JSON tag fixes the wire shape.
|
||||
type InitEvent struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id"`
|
||||
// MessageID anchors a resumed stream (`session resume`) to the
|
||||
// specific assistant message whose event buffer is being replayed. Empty
|
||||
// for fresh streams (chat / session ask) where the message id is only
|
||||
// known after the SDK emits its first agent_query frame.
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
KBID string `json:"kb_id,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
// WriteNDJSONLine emits one NDJSON event line to w.
|
||||
func WriteNDJSONLine(w io.Writer, v any) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// EmitInit writes the lifecycle init event to start an NDJSON stream.
|
||||
// MUST be called before any other writes to w within an NDJSON stream —
|
||||
// agents key on lines[0].type == "init" to discover session/profile/kb
|
||||
// context. Sets Type=init regardless of caller-supplied value.
|
||||
func EmitInit(w io.Writer, ev InitEvent) error {
|
||||
ev.Type = "init"
|
||||
return WriteNDJSONLine(w, ev)
|
||||
}
|
||||
|
||||
// EmitSDKEvent passes through the raw SDK event as one NDJSON line.
|
||||
// The SDK is the source of truth for event vocabulary; the CLI does not
|
||||
// rename or reshape events.
|
||||
func EmitSDKEvent(w io.Writer, ev any) error {
|
||||
return WriteNDJSONLine(w, ev)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package output_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestEmitInit_ForcesTypeField(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
// Pass an InitEvent with Type deliberately set to something wrong; EmitInit
|
||||
// must overwrite it with "init".
|
||||
ev := output.InitEvent{Type: "wrong", SessionID: "sess_x"}
|
||||
if err := output.EmitInit(&buf, ev); err != nil {
|
||||
t.Fatalf("EmitInit: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
|
||||
}
|
||||
if got.Type != "init" {
|
||||
t.Errorf("type: got %q, want init", got.Type)
|
||||
}
|
||||
if got.SessionID != "sess_x" {
|
||||
t.Errorf("session_id: got %q, want sess_x", got.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitInit_OmitsEmptyOptionalFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
// Only SessionID set; all optional fields should be absent from JSON.
|
||||
ev := output.InitEvent{SessionID: "sess_only"}
|
||||
if err := output.EmitInit(&buf, ev); err != nil {
|
||||
t.Fatalf("EmitInit: %v", err)
|
||||
}
|
||||
line := buf.String()
|
||||
for _, field := range []string{"kb_id", "agent_id", "model", "profile", "request_id", "message_id"} {
|
||||
if strings.Contains(line, `"`+field+`"`) {
|
||||
t.Errorf("optional field %q should be omitted when empty, got: %s", field, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitSDKEvent_PassthroughVerbatim(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
payload := map[string]any{"type": "answer", "content": "hi"}
|
||||
if err := output.EmitSDKEvent(&buf, payload); err != nil {
|
||||
t.Fatalf("EmitSDKEvent: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
|
||||
}
|
||||
if got["type"] != "answer" {
|
||||
t.Errorf("type: got %v, want answer", got["type"])
|
||||
}
|
||||
if got["content"] != "hi" {
|
||||
t.Errorf("content: got %v, want hi", got["content"])
|
||||
}
|
||||
// Must be a single NDJSON line terminated by newline.
|
||||
raw := buf.String()
|
||||
if !strings.HasSuffix(raw, "\n") {
|
||||
t.Errorf("NDJSON line must end with newline, got %q", raw)
|
||||
}
|
||||
if strings.Count(raw, "\n") != 1 {
|
||||
t.Errorf("expected exactly one newline, got %d in %q", strings.Count(raw, "\n"), raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package projectlink reads and writes the per-project link file
|
||||
// .weknora/project.yaml that anchors a working directory to a profile+KB.
|
||||
//
|
||||
// Discovery walks up from the start directory until either (a) a
|
||||
// .weknora/project.yaml is found, (b) the filesystem root is reached, or
|
||||
// (c) the walk exceeds a 64-level depth limit (cycle protection for
|
||||
// pathological symlink setups). Pattern matches `cargo`, `npm`, and `git`
|
||||
// - find-the-project's-root walks; mount-boundary crossing is allowed
|
||||
// (npm/cargo behave the same - a project may straddle a bind-mount).
|
||||
package projectlink
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/xdg"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Project is the on-disk schema of .weknora/project.yaml.
|
||||
type Project struct {
|
||||
Profile string `yaml:"profile,omitempty"`
|
||||
KBID string `yaml:"kb_id"`
|
||||
CreatedAt time.Time `yaml:"created_at"`
|
||||
}
|
||||
|
||||
// FileName is the project link file basename relative to .weknora/.
|
||||
const FileName = "project.yaml"
|
||||
|
||||
// DirName is the project-link directory name.
|
||||
const DirName = ".weknora"
|
||||
|
||||
const maxWalkDepth = 64
|
||||
|
||||
// Discover walks up from startDir looking for <DirName>/<FileName>.
|
||||
// Returns (path, true, nil) when found, (root, false, nil) when none is
|
||||
// found within depth limit, or ("", false, err) on filesystem errors.
|
||||
func Discover(startDir string) (string, bool, error) {
|
||||
dir, err := filepath.Abs(startDir)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("resolve start dir: %w", err)
|
||||
}
|
||||
for range maxWalkDepth {
|
||||
candidate := filepath.Join(dir, DirName, FileName)
|
||||
info, err := os.Stat(candidate)
|
||||
if err == nil && !info.IsDir() {
|
||||
return candidate, true, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
// Permission errors etc. - keep walking up; missing read access
|
||||
// to a parent dir doesn't necessarily mean the project starts
|
||||
// here. Cargo/npm have the same behavior.
|
||||
// Fall through to parent walk.
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
// Hit filesystem root
|
||||
return "", false, nil
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// Load reads and parses the project link file at path.
|
||||
func Load(path string) (*Project, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read project link: %w", err)
|
||||
}
|
||||
var p Project
|
||||
if err := yaml.Unmarshal(data, &p); err != nil {
|
||||
return nil, fmt.Errorf("project link corrupt: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Save writes the project link to path atomically (0600 perms).
|
||||
// Creates parent .weknora/ directory if missing.
|
||||
func Save(path string, p *Project) error {
|
||||
return xdg.WriteAtomicYAML(path, p)
|
||||
}
|
||||
|
||||
// Remove deletes the project link at path. A missing file is reported as
|
||||
// success so callers can stay idempotent under concurrent-removal races
|
||||
// - the post-condition (no file at path) holds in either case.
|
||||
func Remove(path string) error {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove project link: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package projectlink_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/projectlink"
|
||||
)
|
||||
|
||||
func TestDiscover_FoundInCwd(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mkLink(t, dir)
|
||||
got, found, err := projectlink.Discover(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected found=true")
|
||||
}
|
||||
want := filepath.Join(dir, ".weknora", "project.yaml")
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_FoundInParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkLink(t, root)
|
||||
deep := filepath.Join(root, "a", "b", "c")
|
||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, found, err := projectlink.Discover(deep)
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected found=true")
|
||||
}
|
||||
if filepath.Dir(filepath.Dir(got)) != root {
|
||||
t.Errorf("expected discovered file under %q, got %q", root, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_NotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, found, err := projectlink.Discover(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected found=false in empty tree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".weknora", "project.yaml")
|
||||
in := &projectlink.Project{Profile: "prod", KBID: "kb_abc", CreatedAt: time.Now().UTC().Round(time.Second)}
|
||||
if err := projectlink.Save(path, in); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
out, err := projectlink.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if out.Profile != in.Profile || out.KBID != in.KBID {
|
||||
t.Errorf("round trip: got %+v want %+v", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Corrupt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".weknora", "project.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("not: yaml: : :"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := projectlink.Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupt yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_CreatesParent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".weknora", "project.yaml")
|
||||
if err := projectlink.Save(path, &projectlink.Project{KBID: "kb_x"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".weknora")); err != nil {
|
||||
t.Errorf("parent .weknora not created: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("file not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mkLink(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
d := filepath.Join(dir, ".weknora")
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, "project.yaml"), []byte("kb_id: kb_test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package prompt defines the interactive-input abstraction. Production
|
||||
// uses charmbracelet/huh (TTYPrompter) for password input; non-TTY
|
||||
// contexts get AgentPrompter, which rejects every interactive call with
|
||||
// ErrAgentNoPrompt so commands can map it to a typed missing-flag error.
|
||||
package prompt
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrAgentNoPrompt is returned by any Prompter call made when the CLI is in
|
||||
// agent / non-interactive mode. Commands should map this to a "missing flag"
|
||||
// user error before returning to cobra.
|
||||
var ErrAgentNoPrompt = errors.New("prompt: interactive input not available in agent/non-interactive mode")
|
||||
|
||||
// Prompter is the small abstraction the auth login flow needs. Tests inject
|
||||
// scripted responses; production wires charmbracelet/huh.
|
||||
type Prompter interface {
|
||||
// Input collects free-form text input from the user.
|
||||
Input(label, defaultValue string) (string, error)
|
||||
// Password collects a secret without echoing.
|
||||
Password(label string) (string, error)
|
||||
// Confirm asks for yes/no confirmation, used by destructive commands.
|
||||
Confirm(label string, defaultValue bool) (bool, error)
|
||||
}
|
||||
|
||||
// AgentPrompter rejects all interactive calls. Use it as the Factory.Prompter
|
||||
// closure result whenever agent mode is detected.
|
||||
type AgentPrompter struct{}
|
||||
|
||||
func (AgentPrompter) Input(string, string) (string, error) { return "", ErrAgentNoPrompt }
|
||||
func (AgentPrompter) Password(string) (string, error) { return "", ErrAgentNoPrompt }
|
||||
func (AgentPrompter) Confirm(string, bool) (bool, error) { return false, ErrAgentNoPrompt }
|
||||
@@ -0,0 +1,18 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAgentPrompter_AlwaysRejects(t *testing.T) {
|
||||
p := AgentPrompter{}
|
||||
_, err := p.Input("label", "default")
|
||||
assert.True(t, errors.Is(err, ErrAgentNoPrompt))
|
||||
_, err = p.Password("password")
|
||||
assert.True(t, errors.Is(err, ErrAgentNoPrompt))
|
||||
_, err = p.Confirm("yes?", false)
|
||||
assert.True(t, errors.Is(err, ErrAgentNoPrompt))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
// TTYPrompter implements Prompter using charmbracelet/huh for interactive
|
||||
// input. Used by the production Factory.Prompter closure when iostreams.IO
|
||||
// is interactive.
|
||||
type TTYPrompter struct{}
|
||||
|
||||
// NewTTYPrompter returns a Prompter backed by huh.
|
||||
func NewTTYPrompter() Prompter { return TTYPrompter{} }
|
||||
|
||||
func (TTYPrompter) Input(label, defaultValue string) (string, error) {
|
||||
v := defaultValue
|
||||
form := huh.NewForm(huh.NewGroup(
|
||||
huh.NewInput().Title(label).Value(&v),
|
||||
))
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("prompt input: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (TTYPrompter) Password(label string) (string, error) {
|
||||
var v string
|
||||
form := huh.NewForm(huh.NewGroup(
|
||||
huh.NewInput().Title(label).EchoMode(huh.EchoModePassword).Value(&v),
|
||||
))
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("prompt password: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (TTYPrompter) Confirm(label string, defaultValue bool) (bool, error) {
|
||||
v := defaultValue
|
||||
form := huh.NewForm(huh.NewGroup(
|
||||
huh.NewConfirm().Title(label).Value(&v),
|
||||
))
|
||||
if err := form.Run(); err != nil {
|
||||
return false, fmt.Errorf("prompt confirm: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// keyringService is the namespace prefix passed to OS keyring backends.
|
||||
// Final keyring entries look like service="weknora:<profile>", account="<key>".
|
||||
const keyringService = "weknora"
|
||||
|
||||
// KeyringStore is the OS-backed credential store: macOS Keychain, GNOME
|
||||
// libsecret, KWallet, Windows Credential Manager. Falls back to ErrUnsupported
|
||||
// (callers swap to FileStore when this happens, see NewBestEffortStore).
|
||||
type KeyringStore struct{}
|
||||
|
||||
// NewKeyringStore returns a KeyringStore. The constructor never fails; first
|
||||
// real Get/Set surfaces ErrUnsupported on systems with no keyring backend.
|
||||
func NewKeyringStore() *KeyringStore { return &KeyringStore{} }
|
||||
|
||||
// service returns the per-profile service identifier. Splitting by profile
|
||||
// (rather than by host) follows the Supabase pattern: a user with
|
||||
// two tenants on the same host gets two distinct keyring namespaces.
|
||||
func (k *KeyringStore) service(profile string) string {
|
||||
return keyringService + ":" + profile
|
||||
}
|
||||
|
||||
func (k *KeyringStore) Get(profile, key string) (string, error) {
|
||||
v, err := keyring.Get(k.service(profile), key)
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keyring get: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (k *KeyringStore) Set(profile, key, value string) error {
|
||||
if err := keyring.Set(k.service(profile), key, value); err != nil {
|
||||
return fmt.Errorf("keyring set: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *KeyringStore) Delete(profile, key string) error {
|
||||
err := keyring.Delete(k.service(profile), key)
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("keyring delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ref returns the keychain:// URI under which a secret is stored.
|
||||
func (k *KeyringStore) Ref(profile, key string) string {
|
||||
return "keychain://" + keyringService + "/" + profile + "/" + key
|
||||
}
|
||||
|
||||
// NewBestEffortStore returns a Store that prefers keyring (when available)
|
||||
// and silently degrades to FileStore on systems without a keyring backend
|
||||
// (e.g. headless CI, WSL without DBus, agent containers).
|
||||
//
|
||||
// Probe is performed by attempting a no-op Get; if the backend signals that
|
||||
// it is unsupported we return the file store directly.
|
||||
func NewBestEffortStore() (Store, error) {
|
||||
k := NewKeyringStore()
|
||||
// Use a sentinel profile/key that should not exist; we expect either
|
||||
// ErrNotFound (backend works, just empty) or ErrUnsupported (no backend).
|
||||
_, err := keyring.Get(k.service("__probe__"), "__probe__")
|
||||
if err == nil || errors.Is(err, keyring.ErrNotFound) {
|
||||
return k, nil
|
||||
}
|
||||
// Anything else (including keyring.ErrUnsupportedPlatform) → file store.
|
||||
return NewFileStore()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// Use the in-memory keyring backend for deterministic tests.
|
||||
//
|
||||
// go-keyring exposes MockInit so tests don't need a real OS keychain.
|
||||
func TestKeyringStore_RoundTrip(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Cleanup(func() {
|
||||
// Reset the mock so other test packages don't see leaked state.
|
||||
keyring.MockInit()
|
||||
})
|
||||
k := NewKeyringStore()
|
||||
|
||||
// Get on missing returns ErrNotFound.
|
||||
_, err := k.Get("prod", "access")
|
||||
require.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// Set + Get.
|
||||
require.NoError(t, k.Set("prod", "access", "jwt-x"))
|
||||
got, err := k.Get("prod", "access")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "jwt-x", got)
|
||||
|
||||
// Delete + Get.
|
||||
require.NoError(t, k.Delete("prod", "access"))
|
||||
_, err = k.Get("prod", "access")
|
||||
require.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// Delete on missing is a no-op.
|
||||
require.NoError(t, k.Delete("prod", "access"))
|
||||
}
|
||||
|
||||
func TestKeyringStore_NamespaceIsolation(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Cleanup(func() { keyring.MockInit() })
|
||||
k := NewKeyringStore()
|
||||
require.NoError(t, k.Set("prod", "access", "P"))
|
||||
require.NoError(t, k.Set("staging", "access", "S"))
|
||||
|
||||
got, _ := k.Get("prod", "access")
|
||||
assert.Equal(t, "P", got)
|
||||
got, _ = k.Get("staging", "access")
|
||||
assert.Equal(t, "S", got)
|
||||
}
|
||||
|
||||
func TestNewBestEffortStore(t *testing.T) {
|
||||
keyring.MockInit() // mock backend "supports" keyring → returns KeyringStore
|
||||
t.Cleanup(func() { keyring.MockInit() })
|
||||
s, err := NewBestEffortStore()
|
||||
require.NoError(t, err)
|
||||
_, ok := s.(*KeyringStore)
|
||||
assert.True(t, ok, "with mock keyring backend, BestEffort must return KeyringStore")
|
||||
}
|
||||
|
||||
func TestKeyringStore_Ref(t *testing.T) {
|
||||
k := NewKeyringStore()
|
||||
got := k.Ref("prod", "access")
|
||||
assert.Equal(t, "keychain://weknora/prod/access", got)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package secrets stores and retrieves credentials. Production wires
|
||||
// KeyringStore (OS keychain, primary) with FileStore (0600 plaintext under
|
||||
// $XDG_CONFIG_HOME/weknora/secrets/, used as a fallback when no keyring
|
||||
// backend is available).
|
||||
//
|
||||
// Namespace convention: "weknora:<profile>:<key>" where key is "access",
|
||||
// "refresh", or "api_key".
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/xdg"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when the requested secret does not exist.
|
||||
var ErrNotFound = errors.New("secret: not found")
|
||||
|
||||
// Store is the abstraction CLI commands depend on; tests inject in-memory impls.
|
||||
//
|
||||
// Ref returns a stable URI (e.g. file://<profile>/<key> or
|
||||
// keychain://weknora/<profile>/<key>) describing where a saved secret lives.
|
||||
// Backends own their scheme so commands never need to type-assert the
|
||||
// concrete implementation.
|
||||
type Store interface {
|
||||
Get(profile, key string) (string, error)
|
||||
Set(profile, key, value string) error
|
||||
Delete(profile, key string) error
|
||||
Ref(profile, key string) string
|
||||
}
|
||||
|
||||
// FileStore writes 0600 plain-text files under $XDG_CONFIG_HOME/weknora/secrets/<profile>.
|
||||
// It is the headless / CI default and the keychain fallback.
|
||||
type FileStore struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewFileStore returns a FileStore rooted at $XDG_CONFIG_HOME/weknora/secrets
|
||||
// (or ~/.config/weknora/secrets if XDG_CONFIG_HOME is unset). Same convention
|
||||
// as config.Path - see that file for the rationale (CLI convention).
|
||||
func NewFileStore() (*FileStore, error) {
|
||||
root, err := defaultRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &FileStore{root: root}, nil
|
||||
}
|
||||
|
||||
func defaultRoot() (string, error) {
|
||||
return xdg.Path("XDG_CONFIG_HOME", ".config", "secrets")
|
||||
}
|
||||
|
||||
func (f *FileStore) path(profile, key string) string {
|
||||
return filepath.Join(f.root, profile, key)
|
||||
}
|
||||
|
||||
func (f *FileStore) Get(profile, key string) (string, error) {
|
||||
data, err := os.ReadFile(f.path(profile, key))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read secret: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (f *FileStore) Set(profile, key, value string) error {
|
||||
dir := filepath.Join(f.root, profile)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("mkdir secrets dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(f.path(profile, key), []byte(value), 0o600); err != nil {
|
||||
return fmt.Errorf("write secret: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FileStore) Delete(profile, key string) error {
|
||||
err := os.Remove(f.path(profile, key))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Ref returns the file:// URI under which a secret would be stored. Path
|
||||
// component is forward-slash-normalized per RFC 8089 (file:///path on Unix,
|
||||
// file:///C:/... on Windows) - the wire format must not depend on platform
|
||||
// path separator since this string is persisted to config.yaml and may be
|
||||
// consumed by tooling on a different OS.
|
||||
func (f *FileStore) Ref(profile, key string) string {
|
||||
p := filepath.ToSlash(f.path(profile, key))
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
// Windows absolute path "C:/..." needs a leading slash to form a
|
||||
// proper file:/// URI ("file:///C:/...").
|
||||
p = "/" + p
|
||||
}
|
||||
return "file://" + p
|
||||
}
|
||||
|
||||
// MemStore is an in-memory implementation of Store used by tests across
|
||||
// packages. It is intentionally exported (not _test.go-only) so that
|
||||
// downstream packages can compose it without copying the same 20 lines.
|
||||
type MemStore struct{ m map[string]string }
|
||||
|
||||
// NewMemStore returns an empty in-memory secrets store.
|
||||
func NewMemStore() *MemStore { return &MemStore{m: map[string]string{}} }
|
||||
|
||||
func (m *MemStore) k(c, key string) string { return c + ":" + key }
|
||||
|
||||
func (m *MemStore) Get(c, key string) (string, error) {
|
||||
v, ok := m.m[m.k(c, key)]
|
||||
if !ok {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (m *MemStore) Set(c, key, value string) error { m.m[m.k(c, key)] = value; return nil }
|
||||
|
||||
func (m *MemStore) Delete(c, key string) error { delete(m.m, m.k(c, key)); return nil }
|
||||
|
||||
// Ref returns a mem:// URI; meaningful only inside tests.
|
||||
func (m *MemStore) Ref(c, key string) string { return "mem://" + c + "/" + key }
|
||||
@@ -0,0 +1,87 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
)
|
||||
|
||||
func TestFileStore_RoundTrip(t *testing.T) {
|
||||
testutil.XDGTempDir(t)
|
||||
s, err := NewFileStore()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.Get("prod", "access")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrNotFound))
|
||||
|
||||
require.NoError(t, s.Set("prod", "access", "secret-value"))
|
||||
got, err := s.Get("prod", "access")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "secret-value", got)
|
||||
|
||||
require.NoError(t, s.Delete("prod", "access"))
|
||||
_, err = s.Get("prod", "access")
|
||||
require.True(t, errors.Is(err, ErrNotFound))
|
||||
|
||||
require.NoError(t, s.Delete("prod", "access"), "Delete on missing must be a no-op")
|
||||
}
|
||||
|
||||
func TestFileStore_FileMode(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("file mode bits don't map to Windows ACLs; 0600 invariant is Unix-only")
|
||||
}
|
||||
testutil.XDGTempDir(t)
|
||||
s, err := NewFileStore()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Set("prod", "access", "v"))
|
||||
|
||||
st, err := os.Stat(s.path("prod", "access"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, os.FileMode(0o600), st.Mode().Perm(), "secrets must be 0600")
|
||||
}
|
||||
|
||||
func TestFileStore_NamespaceIsolation(t *testing.T) {
|
||||
testutil.XDGTempDir(t)
|
||||
s, err := NewFileStore()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Set("prod", "access", "P"))
|
||||
require.NoError(t, s.Set("staging", "access", "S"))
|
||||
|
||||
got, _ := s.Get("prod", "access")
|
||||
assert.Equal(t, "P", got)
|
||||
got, _ = s.Get("staging", "access")
|
||||
assert.Equal(t, "S", got)
|
||||
}
|
||||
|
||||
func TestFileStore_Ref(t *testing.T) {
|
||||
testutil.XDGTempDir(t)
|
||||
s, err := NewFileStore()
|
||||
require.NoError(t, err)
|
||||
ref := s.Ref("prod", "access")
|
||||
// Ref is RFC 8089: scheme + slash-normalized path. On Windows the path
|
||||
// starts with a drive letter (file:///C:/...); on Unix file:///home/...
|
||||
assert.True(t, strings.HasPrefix(ref, "file:///"), "expected file:/// scheme, got %q", ref)
|
||||
assert.Contains(t, ref, "/prod/access")
|
||||
}
|
||||
|
||||
func TestMemStore_RoundTripAndRef(t *testing.T) {
|
||||
s := NewMemStore()
|
||||
_, err := s.Get("p", "k")
|
||||
require.ErrorIs(t, err, ErrNotFound)
|
||||
require.NoError(t, s.Set("p", "k", "v"))
|
||||
got, err := s.Get("p", "k")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v", got)
|
||||
assert.Equal(t, "mem://p/k", s.Ref("p", "k"))
|
||||
require.NoError(t, s.Delete("p", "k"))
|
||||
_, err = s.Get("p", "k")
|
||||
require.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Package skillparity contains the K6 drift guard: every weknora command and
|
||||
// long flag referenced in a bundled Agent Skill (cli/skills/**) must still
|
||||
// exist in the live cobra command tree. A skill that references a renamed or
|
||||
// removed flag/command is worse than no skill, so this fails CI on drift.
|
||||
package skillparity
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/cmd"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
var (
|
||||
codeFence = regexp.MustCompile("(?s)```[a-zA-Z]*\\n(.*?)```")
|
||||
longFlag = regexp.MustCompile(`--[a-zA-Z][a-zA-Z0-9-]*`)
|
||||
)
|
||||
|
||||
func skillsRoot(t *testing.T) string {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "skills")
|
||||
}
|
||||
|
||||
// allFlagNames collects every long flag name reachable in the command tree
|
||||
// (each command's local + persistent + inherited flags, plus root persistent
|
||||
// and the cobra-added help/version flags).
|
||||
func allFlagNames(root *cobra.Command) map[string]bool {
|
||||
set := map[string]bool{"help": true, "version": true}
|
||||
add := func(fs *pflag.FlagSet) {
|
||||
fs.VisitAll(func(f *pflag.Flag) { set[f.Name] = true })
|
||||
}
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
add(c.LocalFlags())
|
||||
add(c.PersistentFlags())
|
||||
add(c.InheritedFlags())
|
||||
for _, sub := range c.Commands() {
|
||||
walk(sub)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return set
|
||||
}
|
||||
|
||||
// globalTakesValue reports, for each root persistent flag (long + short), whether
|
||||
// it consumes the following token as a value (non-bool).
|
||||
func globalTakesValue(root *cobra.Command) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
root.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||
takes := f.Value.Type() != "bool"
|
||||
m["--"+f.Name] = takes
|
||||
if f.Shorthand != "" {
|
||||
m["-"+f.Shorthand] = takes
|
||||
}
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestSkillsReferenceLiveCommandsAndFlags(t *testing.T) {
|
||||
root := cmd.NewRootCmd(&cmdutil.Factory{})
|
||||
flags := allFlagNames(root)
|
||||
globals := globalTakesValue(root)
|
||||
|
||||
files := skillMarkdownFiles(t, skillsRoot(t))
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no skill markdown files found under cli/skills/")
|
||||
}
|
||||
|
||||
checkedAny := false
|
||||
for _, path := range files {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
// Normalize CRLF → LF before parsing: Windows CI checks out *.md with
|
||||
// CRLF (git autocrlf), and the \n-anchored codeFence regex would never
|
||||
// match ```weknora\r\n, resolving zero commands. Keep the tokenizer
|
||||
// OS-independent rather than depend on checkout line endings.
|
||||
content := strings.ReplaceAll(string(raw), "\r\n", "\n")
|
||||
rel, _ := filepath.Rel(skillsRoot(t), path)
|
||||
for _, block := range codeFence.FindAllStringSubmatch(content, -1) {
|
||||
for _, line := range strings.Split(block[1], "\n") {
|
||||
idx := strings.Index(line, "weknora ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
inv := line[idx+len("weknora"):]
|
||||
toks := strings.Fields(inv)
|
||||
if len(toks) == 0 {
|
||||
continue
|
||||
}
|
||||
// Skip leading global flags (and their value tokens).
|
||||
i := 0
|
||||
for i < len(toks) && strings.HasPrefix(toks[i], "-") {
|
||||
tok := toks[i]
|
||||
if eq := strings.Index(tok, "="); eq >= 0 {
|
||||
tok = tok[:eq]
|
||||
}
|
||||
i++
|
||||
if globals[tok] && i < len(toks) {
|
||||
i++ // consume the value
|
||||
}
|
||||
}
|
||||
// Placeholder command (e.g. `weknora <command> --help`) → skip.
|
||||
if i < len(toks) && strings.HasPrefix(toks[i], "<") {
|
||||
continue
|
||||
}
|
||||
// Greedily descend subcommands while the next token is one.
|
||||
curr := root
|
||||
for i < len(toks) {
|
||||
sub := findSub(curr, toks[i])
|
||||
if sub == nil {
|
||||
break
|
||||
}
|
||||
curr = sub
|
||||
i++
|
||||
}
|
||||
if curr != root {
|
||||
checkedAny = true
|
||||
}
|
||||
// Every long flag in the invocation must exist somewhere in the tree.
|
||||
for _, m := range longFlag.FindAllString(inv, -1) {
|
||||
name := strings.TrimPrefix(m, "--")
|
||||
if !flags[name] {
|
||||
t.Errorf("%s: skill references unknown flag --%s (renamed/removed? line: %q)", rel, name, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !checkedAny {
|
||||
t.Fatal("parser resolved no weknora commands from skills — check tokenizer")
|
||||
}
|
||||
}
|
||||
|
||||
func findSub(c *cobra.Command, name string) *cobra.Command {
|
||||
for _, sub := range c.Commands() {
|
||||
if sub.Name() == name {
|
||||
return sub
|
||||
}
|
||||
for _, a := range sub.Aliases {
|
||||
if a == name {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillMarkdownFiles(t *testing.T, root string) []string {
|
||||
var out []string
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && strings.HasSuffix(p, ".md") {
|
||||
out = append(out, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package sse provides helpers for consuming server-sent event streams from
|
||||
// the WeKnora SDK (KnowledgeQAStream, ContinueStream).
|
||||
//
|
||||
// Accumulator is the canonical sink: every callback event appends to a
|
||||
// buffered Content string and updates terminal-state fields like References
|
||||
// and SessionID. The non-streaming JSON mode reads .Result() once .Done()
|
||||
// is true; streaming mode writes Content tokens directly to stdout and
|
||||
// only consults the accumulator for the final References footer.
|
||||
package sse
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// Accumulator buffers a KnowledgeQAStream callback sequence.
|
||||
//
|
||||
// Zero value is ready to use. Not safe for concurrent Append calls - the SDK
|
||||
// invokes its callback sequentially on a single goroutine, so this matches
|
||||
// the only contract that exists today.
|
||||
//
|
||||
// Demuxes by ResponseType so the answer string is not polluted by thinking
|
||||
// or reflection fragments - the model layer (internal/models/chat/
|
||||
// remote_api.go) emits ResponseTypeThinking events whenever the upstream
|
||||
// LLM produces reasoning_content frames, and without demux those would
|
||||
// be silently concatenated into Result().
|
||||
type Accumulator struct {
|
||||
answer strings.Builder
|
||||
thinking strings.Builder
|
||||
References []*sdk.SearchResult
|
||||
SessionID string
|
||||
AssistantMessageID string
|
||||
finished bool
|
||||
}
|
||||
|
||||
// Append consumes one StreamResponse event. Safe to call multiple times;
|
||||
// idempotent post-Done so callers do not need to special-case late events.
|
||||
func (a *Accumulator) Append(r *sdk.StreamResponse) {
|
||||
if r == nil || a.finished {
|
||||
return
|
||||
}
|
||||
switch r.ResponseType {
|
||||
case sdk.ResponseTypeAnswer:
|
||||
if r.Content != "" {
|
||||
a.answer.WriteString(r.Content)
|
||||
}
|
||||
case sdk.ResponseTypeThinking, sdk.ResponseTypeReflection:
|
||||
if r.Content != "" {
|
||||
a.thinking.WriteString(r.Content)
|
||||
}
|
||||
default:
|
||||
// Frames without a typed ResponseType (legacy / metadata-only
|
||||
// payloads) that still carry an answer fragment fall through here.
|
||||
// Preserve the legacy contract by treating untyped Content as
|
||||
// answer text.
|
||||
if r.ResponseType == "" && r.Content != "" {
|
||||
a.answer.WriteString(r.Content)
|
||||
}
|
||||
}
|
||||
if r.SessionID != "" && a.SessionID == "" {
|
||||
a.SessionID = r.SessionID
|
||||
}
|
||||
if r.AssistantMessageID != "" && a.AssistantMessageID == "" {
|
||||
a.AssistantMessageID = r.AssistantMessageID
|
||||
}
|
||||
// Capture references whenever they arrive - they may land on a
|
||||
// dedicated `references` event before the terminal `complete`.
|
||||
if r.KnowledgeReferences != nil {
|
||||
a.References = r.KnowledgeReferences
|
||||
}
|
||||
// Stream is only truly done on response_type=complete. Other events
|
||||
// (notably the leading agent_query metadata frame) carry done=true to
|
||||
// mark their own per-event completion, but the answer fragments arrive
|
||||
// in subsequent response_type=answer events. Gating on response_type
|
||||
// avoids the off-by-one termination that would discard the entire
|
||||
// answer.
|
||||
if r.ResponseType == sdk.ResponseTypeComplete {
|
||||
a.finished = true
|
||||
}
|
||||
}
|
||||
|
||||
// Done reports whether a terminal event was observed.
|
||||
func (a *Accumulator) Done() bool { return a.finished }
|
||||
|
||||
// Result returns the accumulated answer-event content.
|
||||
func (a *Accumulator) Result() string { return a.answer.String() }
|
||||
|
||||
// Thinking returns the accumulated reasoning / reflection content surfaced
|
||||
// by the upstream LLM (only populated when the active model produces
|
||||
// reasoning_content). Empty for non-reasoning models.
|
||||
func (a *Accumulator) Thinking() string { return a.thinking.String() }
|
||||
@@ -0,0 +1,123 @@
|
||||
package sse_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/sse"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// terminator is the canonical "stream is done" event the accumulator
|
||||
// recognises. The server emits response_type=complete after the answer
|
||||
// fragments and any references; per-event done=true on agent_query /
|
||||
// answer frames is NOT terminal.
|
||||
var terminator = &sdk.StreamResponse{ResponseType: sdk.ResponseTypeComplete, Done: true}
|
||||
|
||||
func TestAccumulator_AppendsContent(t *testing.T) {
|
||||
a := &sse.Accumulator{}
|
||||
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "Hello "})
|
||||
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "world"})
|
||||
if got := a.Result(); got != "Hello world" {
|
||||
t.Errorf("got %q, want %q", got, "Hello world")
|
||||
}
|
||||
if a.Done() {
|
||||
t.Error("expected Done=false before terminal event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccumulator_FinalizesOnComplete(t *testing.T) {
|
||||
a := &sse.Accumulator{}
|
||||
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "answer"})
|
||||
// References may arrive on a dedicated event before the terminator.
|
||||
a.Append(&sdk.StreamResponse{
|
||||
ResponseType: sdk.ResponseTypeReferences,
|
||||
KnowledgeReferences: []*sdk.SearchResult{{KnowledgeID: "k1"}},
|
||||
})
|
||||
a.Append(terminator)
|
||||
if !a.Done() {
|
||||
t.Error("expected Done=true after response_type=complete")
|
||||
}
|
||||
if len(a.References) != 1 {
|
||||
t.Errorf("expected 1 reference, got %d", len(a.References))
|
||||
}
|
||||
if a.References[0].KnowledgeID != "k1" {
|
||||
t.Errorf("references payload not preserved: %+v", a.References[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccumulator_IgnoresAgentQueryDone(t *testing.T) {
|
||||
// Server emits a leading agent_query frame with done=true to deliver
|
||||
// session metadata; the accumulator must NOT treat that as terminal -
|
||||
// otherwise the answer fragments that follow would be discarded.
|
||||
a := &sse.Accumulator{}
|
||||
a.Append(&sdk.StreamResponse{
|
||||
ResponseType: sdk.ResponseTypeAgentQuery,
|
||||
Done: true,
|
||||
SessionID: "sess_123",
|
||||
AssistantMessageID: "msg_456",
|
||||
})
|
||||
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "real answer"})
|
||||
a.Append(terminator)
|
||||
if !a.Done() {
|
||||
t.Error("expected Done=true after response_type=complete")
|
||||
}
|
||||
if got := a.Result(); got != "real answer" {
|
||||
t.Errorf("agent_query done=true should not terminate; got %q", got)
|
||||
}
|
||||
if a.SessionID != "sess_123" {
|
||||
t.Errorf("session metadata from agent_query frame lost: got %q", a.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccumulator_IgnoresPostComplete(t *testing.T) {
|
||||
a := &sse.Accumulator{}
|
||||
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "first"})
|
||||
a.Append(terminator)
|
||||
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "after"})
|
||||
if got := a.Result(); got != "first" {
|
||||
t.Errorf("post-complete append should be no-op, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccumulator_NilSafe(t *testing.T) {
|
||||
a := &sse.Accumulator{}
|
||||
a.Append(nil)
|
||||
if got := a.Result(); got != "" {
|
||||
t.Errorf("expected empty result for nil append, got %q", got)
|
||||
}
|
||||
if a.Done() {
|
||||
t.Error("nil append must not finalize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccumulator_CapturesSessionMetadata(t *testing.T) {
|
||||
a := &sse.Accumulator{}
|
||||
a.Append(&sdk.StreamResponse{
|
||||
ResponseType: sdk.ResponseTypeAnswer,
|
||||
SessionID: "sess_123",
|
||||
AssistantMessageID: "msg_456",
|
||||
Content: "hi",
|
||||
})
|
||||
a.Append(terminator)
|
||||
if a.SessionID != "sess_123" {
|
||||
t.Errorf("SessionID: got %q", a.SessionID)
|
||||
}
|
||||
if a.AssistantMessageID != "msg_456" {
|
||||
t.Errorf("AssistantMessageID: got %q", a.AssistantMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccumulator_FirstSessionMetadataWins(t *testing.T) {
|
||||
// Subsequent events must not overwrite the first non-empty value - the
|
||||
// session id is set once at session start and any later override would be
|
||||
// a server bug we should not silently mask.
|
||||
a := &sse.Accumulator{}
|
||||
a.Append(&sdk.StreamResponse{SessionID: "sess_first", AssistantMessageID: "msg_first"})
|
||||
a.Append(&sdk.StreamResponse{SessionID: "sess_second", AssistantMessageID: "msg_second"})
|
||||
if a.SessionID != "sess_first" {
|
||||
t.Errorf("SessionID overwritten: got %q want sess_first", a.SessionID)
|
||||
}
|
||||
if a.AssistantMessageID != "msg_first" {
|
||||
t.Errorf("AssistantMessageID overwritten: got %q want msg_first", a.AssistantMessageID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// AgentToolEvent captures one tool_call / tool_result event from an
|
||||
// agent SSE stream. Kind is the SDK event type (typed, not bare string)
|
||||
// so consumers can compare against sdk.AgentResponseTypeToolCall /
|
||||
// sdk.AgentResponseTypeToolResult without retyping the constants.
|
||||
// NOTE: Kind is the event kind, NOT the function name; the function
|
||||
// name typically lives in Data for tool_call events.
|
||||
type AgentToolEvent struct {
|
||||
ID string `json:"id"`
|
||||
Kind sdk.AgentResponseType `json:"kind,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// AgentAccumulator buffers an AgentQAStream callback sequence. Distinct
|
||||
// from Accumulator (KnowledgeQAStream) because the agent event model is
|
||||
// wider - events include thinking / reflection / tool_call / tool_result
|
||||
// / answer / references / error / complete. Done is scoped to an individual
|
||||
// event stream (thinking, reflection, answer, etc.); only response_type=complete
|
||||
// terminates the whole agent stream.
|
||||
//
|
||||
// Zero value is ready to use. Not safe for concurrent Append calls - the
|
||||
// SDK callback contract is sequential on a single goroutine.
|
||||
//
|
||||
// API mirrors sse.Accumulator: private builders + accessor methods so the
|
||||
// "Append is idempotent post-Done" invariant cannot be broken by external
|
||||
// mutation. References and ToolEvents stay exported as plain slices since
|
||||
// they carry no such invariant.
|
||||
type AgentAccumulator struct {
|
||||
answer strings.Builder
|
||||
thinking strings.Builder
|
||||
References []*sdk.SearchResult
|
||||
ToolEvents []AgentToolEvent
|
||||
done bool
|
||||
}
|
||||
|
||||
// Answer returns the accumulated `answer`-event content.
|
||||
func (a *AgentAccumulator) Answer() string { return a.answer.String() }
|
||||
|
||||
// Thinking returns the accumulated `thinking` / `reflection` content
|
||||
// surfaced by the agent during its reasoning pass.
|
||||
func (a *AgentAccumulator) Thinking() string { return a.thinking.String() }
|
||||
|
||||
// Done reports whether the stream emitted a terminal frame.
|
||||
func (a *AgentAccumulator) Done() bool { return a.done }
|
||||
|
||||
// Append consumes one AgentStreamResponse event. Idempotent post-Done so
|
||||
// callers do not need to special-case late events.
|
||||
func (a *AgentAccumulator) Append(r *sdk.AgentStreamResponse) {
|
||||
if r == nil || a.done {
|
||||
return
|
||||
}
|
||||
switch r.ResponseType {
|
||||
case sdk.AgentResponseTypeAnswer:
|
||||
if r.Content != "" {
|
||||
a.answer.WriteString(r.Content)
|
||||
}
|
||||
case sdk.AgentResponseTypeThinking, sdk.AgentResponseTypeReflection:
|
||||
if r.Content != "" {
|
||||
a.thinking.WriteString(r.Content)
|
||||
}
|
||||
case sdk.AgentResponseTypeToolCall, sdk.AgentResponseTypeToolResult:
|
||||
a.ToolEvents = append(a.ToolEvents, AgentToolEvent{
|
||||
ID: r.ID,
|
||||
Kind: r.ResponseType,
|
||||
Result: r.Content,
|
||||
Data: r.Data,
|
||||
})
|
||||
}
|
||||
// References can arrive on a dedicated `references` event OR
|
||||
// piggyback on another event's KnowledgeReferences field; always
|
||||
// capture the latest, matching sse.Accumulator's "always replace"
|
||||
// semantic for the parallel KnowledgeQAStream contract.
|
||||
if r.KnowledgeReferences != nil {
|
||||
a.References = r.KnowledgeReferences
|
||||
}
|
||||
// Thinking / reflection / answer streams each emit their own Done=true
|
||||
// marker before later events arrive. Only the explicit complete frame is
|
||||
// terminal for the whole agent run.
|
||||
if r.ResponseType == sdk.AgentResponseTypeComplete {
|
||||
a.done = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package sse_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/sse"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
func TestAgentAccumulator_FinalizesOnlyOnComplete(t *testing.T) {
|
||||
a := &sse.AgentAccumulator{}
|
||||
preTerminal := []*sdk.AgentStreamResponse{
|
||||
{ResponseType: sdk.AgentResponseTypeThinking, Content: "think", Done: true},
|
||||
{ResponseType: sdk.AgentResponseTypeToolCall, ID: "call_1", Content: "search"},
|
||||
{ResponseType: sdk.AgentResponseTypeReflection, Content: "reflect", Done: true},
|
||||
{ResponseType: sdk.AgentResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{
|
||||
{ID: "chunk_1", Content: "bulky passage"},
|
||||
}},
|
||||
{ResponseType: sdk.AgentResponseTypeAnswer, Content: "final answer", Done: true},
|
||||
}
|
||||
for _, event := range preTerminal {
|
||||
a.Append(event)
|
||||
if a.Done() {
|
||||
t.Fatalf("%s Done=true terminated the whole stream", event.ResponseType)
|
||||
}
|
||||
}
|
||||
|
||||
a.Append(&sdk.AgentStreamResponse{ResponseType: sdk.AgentResponseTypeComplete, Done: true})
|
||||
if !a.Done() {
|
||||
t.Fatal("complete event did not terminate the stream")
|
||||
}
|
||||
if got := a.Answer(); got != "final answer" {
|
||||
t.Errorf("answer=%q, want final answer", got)
|
||||
}
|
||||
if got := a.Thinking(); got != "thinkreflect" {
|
||||
t.Errorf("thinking=%q, want thinkreflect", got)
|
||||
}
|
||||
if len(a.ToolEvents) != 1 {
|
||||
t.Errorf("tool_events=%d, want 1", len(a.ToolEvents))
|
||||
}
|
||||
if len(a.References) != 1 || a.References[0].Content != "bulky passage" {
|
||||
t.Errorf("references were not preserved: %+v", a.References)
|
||||
}
|
||||
|
||||
a.Append(&sdk.AgentStreamResponse{ResponseType: sdk.AgentResponseTypeAnswer, Content: "late"})
|
||||
if got := a.Answer(); got != "final answer" {
|
||||
t.Errorf("post-complete event was appended: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// ProjectedEvent is the bounded event representation shared by
|
||||
// buffered JSON, human-readable text, and MCP tool results. It deliberately
|
||||
// mirrors the SDK field names so consumers can move between projected JSON and
|
||||
// raw NDJSON without learning a second event vocabulary.
|
||||
type ProjectedEvent struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
ResponseType string `json:"response_type"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
KnowledgeReferences []format.ReferenceIndex `json:"knowledge_references,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
AssistantMessageID string `json:"assistant_message_id,omitempty"`
|
||||
ToolCalls []sdk.LLMToolCall `json:"tool_calls,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Projector observes every SDK frame for lifecycle state, then returns the
|
||||
// presentation event selected by the output contract. Default output keeps
|
||||
// answer frames only. Reference enables bounded citation events; verbose
|
||||
// enables execution and lifecycle events.
|
||||
type Projector struct {
|
||||
verbose bool
|
||||
reference bool
|
||||
fallbackKBID string
|
||||
seen bool
|
||||
done bool
|
||||
sessionID string
|
||||
assistantMessageID string
|
||||
}
|
||||
|
||||
func NewProjector(verbose, reference bool, fallbackKBID string) *Projector {
|
||||
return &Projector{verbose: verbose, reference: reference, fallbackKBID: fallbackKBID}
|
||||
}
|
||||
|
||||
func (p *Projector) Seen() bool { return p.seen }
|
||||
func (p *Projector) Done() bool { return p.done }
|
||||
func (p *Projector) SessionID() string { return p.sessionID }
|
||||
func (p *Projector) AssistantMessageID() string { return p.assistantMessageID }
|
||||
|
||||
// Chat consumes one KnowledgeQAStream frame.
|
||||
func (p *Projector) Chat(r *sdk.StreamResponse) (ProjectedEvent, bool) {
|
||||
if r == nil || p.done {
|
||||
return ProjectedEvent{}, false
|
||||
}
|
||||
p.seen = true
|
||||
if p.sessionID == "" && r.SessionID != "" {
|
||||
p.sessionID = r.SessionID
|
||||
}
|
||||
if p.assistantMessageID == "" && r.AssistantMessageID != "" {
|
||||
p.assistantMessageID = r.AssistantMessageID
|
||||
}
|
||||
if r.ResponseType == sdk.ResponseTypeComplete {
|
||||
p.done = true
|
||||
}
|
||||
if r.ResponseType == sdk.ResponseTypeError && r.Done {
|
||||
p.done = true
|
||||
}
|
||||
|
||||
responseType := string(r.ResponseType)
|
||||
isAnswer := r.ResponseType == sdk.ResponseTypeAnswer || (r.ResponseType == "" && r.Content != "")
|
||||
isReference := r.ResponseType == sdk.ResponseTypeReferences
|
||||
include := isAnswer || (p.verbose && !isReference) || (p.reference && isReference)
|
||||
if !include && p.reference && len(r.KnowledgeReferences) > 0 {
|
||||
return p.chatReferenceEvent(r), true
|
||||
}
|
||||
if !include {
|
||||
return ProjectedEvent{}, false
|
||||
}
|
||||
if responseType == "" {
|
||||
responseType = string(sdk.ResponseTypeAnswer)
|
||||
}
|
||||
event := ProjectedEvent{
|
||||
ID: r.ID,
|
||||
ResponseType: responseType,
|
||||
Content: r.Content,
|
||||
Done: r.Done,
|
||||
SessionID: r.SessionID,
|
||||
AssistantMessageID: r.AssistantMessageID,
|
||||
ToolCalls: r.ToolCalls,
|
||||
Data: r.Data,
|
||||
}
|
||||
if p.reference {
|
||||
event.KnowledgeReferences = format.IndexReferences(r.KnowledgeReferences, p.fallbackKBID)
|
||||
}
|
||||
return event, true
|
||||
}
|
||||
|
||||
// Agent consumes one AgentQAStream frame.
|
||||
func (p *Projector) Agent(r *sdk.AgentStreamResponse) (ProjectedEvent, bool) {
|
||||
if r == nil || p.done {
|
||||
return ProjectedEvent{}, false
|
||||
}
|
||||
p.seen = true
|
||||
if r.ResponseType == sdk.AgentResponseTypeComplete {
|
||||
p.done = true
|
||||
}
|
||||
if r.ResponseType == sdk.AgentResponseTypeError && r.Done {
|
||||
p.done = true
|
||||
}
|
||||
|
||||
isAnswer := r.ResponseType == sdk.AgentResponseTypeAnswer
|
||||
isReference := r.ResponseType == sdk.AgentResponseTypeReferences
|
||||
include := isAnswer || (p.verbose && !isReference) || (p.reference && isReference)
|
||||
if !include && p.reference && len(r.KnowledgeReferences) > 0 {
|
||||
return p.agentReferenceEvent(r), true
|
||||
}
|
||||
if !include {
|
||||
return ProjectedEvent{}, false
|
||||
}
|
||||
event := ProjectedEvent{
|
||||
ID: r.ID,
|
||||
ResponseType: string(r.ResponseType),
|
||||
Content: r.Content,
|
||||
Done: r.Done,
|
||||
Data: r.Data,
|
||||
}
|
||||
if p.reference {
|
||||
event.KnowledgeReferences = format.IndexReferences(r.KnowledgeReferences, p.fallbackKBID)
|
||||
}
|
||||
return event, true
|
||||
}
|
||||
|
||||
func (p *Projector) chatReferenceEvent(r *sdk.StreamResponse) ProjectedEvent {
|
||||
return ProjectedEvent{
|
||||
ID: r.ID,
|
||||
ResponseType: string(sdk.ResponseTypeReferences),
|
||||
Done: r.Done,
|
||||
KnowledgeReferences: format.IndexReferences(r.KnowledgeReferences, p.fallbackKBID),
|
||||
SessionID: r.SessionID,
|
||||
AssistantMessageID: r.AssistantMessageID,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Projector) agentReferenceEvent(r *sdk.AgentStreamResponse) ProjectedEvent {
|
||||
return ProjectedEvent{
|
||||
ID: r.ID,
|
||||
ResponseType: string(sdk.AgentResponseTypeReferences),
|
||||
Done: r.Done,
|
||||
KnowledgeReferences: format.IndexReferences(r.KnowledgeReferences, p.fallbackKBID),
|
||||
}
|
||||
}
|
||||
|
||||
// TextRenderer writes projected events as a human-readable stream.
|
||||
// TTY detection is intentionally absent: terminals and pipes receive the same
|
||||
// filtering semantics; callers may add styling later without changing them.
|
||||
type TextRenderer struct {
|
||||
w io.Writer
|
||||
verbose bool
|
||||
lastKey string
|
||||
atBOL bool
|
||||
}
|
||||
|
||||
func NewTextRenderer(w io.Writer, verbose bool) *TextRenderer {
|
||||
return &TextRenderer{w: w, verbose: verbose, atBOL: true}
|
||||
}
|
||||
|
||||
func (r *TextRenderer) Write(ev ProjectedEvent) error {
|
||||
switch ev.ResponseType {
|
||||
case string(sdk.ResponseTypeAnswer):
|
||||
return r.writeContentBlock(ev, "answer")
|
||||
case string(sdk.ResponseTypeThinking), string(sdk.ResponseTypeReflection):
|
||||
return r.writeContentBlock(ev, ev.ResponseType)
|
||||
case string(sdk.ResponseTypeReferences):
|
||||
return r.writeReferences(ev)
|
||||
default:
|
||||
return r.writeStructured(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TextRenderer) Close() error {
|
||||
if !r.atBOL {
|
||||
return r.writeString("\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TextRenderer) writeContentBlock(ev ProjectedEvent, label string) error {
|
||||
key := ev.ResponseType + "\x00" + ev.ID
|
||||
if key != r.lastKey {
|
||||
if err := r.ensureBOL(); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.verbose {
|
||||
if err := r.writeString("[" + label + "]\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r.lastKey = key
|
||||
}
|
||||
if ev.Content != "" {
|
||||
if err := r.writeString(ev.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if ev.Done {
|
||||
r.lastKey = ""
|
||||
return r.ensureBOL()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TextRenderer) writeReferences(ev ProjectedEvent) error {
|
||||
if err := r.ensureBOL(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.writeString("[references]\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ref := range ev.KnowledgeReferences {
|
||||
line := "- chunk_id=" + ref.ChunkID
|
||||
if ref.KBID != "" {
|
||||
line += " kb_id=" + ref.KBID
|
||||
}
|
||||
if ref.ParentChunkID != "" {
|
||||
line += " parent_chunk_id=" + ref.ParentChunkID
|
||||
}
|
||||
if err := r.writeString(line + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r.lastKey = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TextRenderer) writeStructured(ev ProjectedEvent) error {
|
||||
if err := r.ensureBOL(); err != nil {
|
||||
return err
|
||||
}
|
||||
header := "[" + ev.ResponseType
|
||||
if ev.ID != "" {
|
||||
header += " " + ev.ID
|
||||
}
|
||||
header += "]"
|
||||
if err := r.writeString(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.Content != "" {
|
||||
if err := r.writeString(" " + strings.ReplaceAll(ev.Content, "\n", " ")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(ev.ToolCalls) > 0 {
|
||||
b, _ := json.Marshal(ev.ToolCalls)
|
||||
if err := r.writeString(" " + string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(ev.Data) > 0 {
|
||||
b, _ := json.Marshal(ev.Data)
|
||||
if err := r.writeString(" " + string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := r.writeString("\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
r.lastKey = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TextRenderer) ensureBOL() error {
|
||||
if r.atBOL {
|
||||
return nil
|
||||
}
|
||||
return r.writeString("\n")
|
||||
}
|
||||
|
||||
func (r *TextRenderer) writeString(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprint(r.w, s); err != nil {
|
||||
return err
|
||||
}
|
||||
r.atBOL = strings.HasSuffix(s, "\n")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package sse_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/sse"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
func TestProjector_DefaultKeepsOnlyAnswerEvents(t *testing.T) {
|
||||
p := sse.NewProjector(false, false, "kb_fallback")
|
||||
input := []*sdk.StreamResponse{
|
||||
{ID: "think", ResponseType: sdk.ResponseTypeThinking, Content: "hidden"},
|
||||
{ID: "a", ResponseType: sdk.ResponseTypeAnswer, Content: "one", KnowledgeReferences: []*sdk.SearchResult{{ID: "piggyback", Content: "bulk"}}},
|
||||
{ID: "refs", ResponseType: sdk.ResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{{ID: "c1", Content: "bulk"}}},
|
||||
{ID: "a", ResponseType: sdk.ResponseTypeAnswer, Content: "two", Done: true},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true},
|
||||
}
|
||||
var got []sse.ProjectedEvent
|
||||
for _, event := range input {
|
||||
if projected, ok := p.Chat(event); ok {
|
||||
got = append(got, projected)
|
||||
}
|
||||
}
|
||||
if !p.Done() || len(got) != 2 {
|
||||
t.Fatalf("done=%v events=%+v", p.Done(), got)
|
||||
}
|
||||
if got[0].Content != "one" || got[1].Content != "two" {
|
||||
t.Errorf("answer order changed: %+v", got)
|
||||
}
|
||||
if len(got[0].KnowledgeReferences) != 0 {
|
||||
t.Errorf("default answer leaked piggyback references: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjector_VerboseAndReferenceIncludeBothDetailClasses(t *testing.T) {
|
||||
p := sse.NewProjector(true, true, "kb_fallback")
|
||||
input := []*sdk.AgentStreamResponse{
|
||||
{ID: "t", ResponseType: sdk.AgentResponseTypeThinking, Content: "think"},
|
||||
{ID: "call", ResponseType: sdk.AgentResponseTypeToolCall, Content: "search"},
|
||||
{ID: "refs", ResponseType: sdk.AgentResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{{ID: "c1", KnowledgeBaseID: "kb1", ParentChunkID: "p1", Content: "bulk"}}},
|
||||
{ID: "a", ResponseType: sdk.AgentResponseTypeAnswer, Content: "answer"},
|
||||
{ResponseType: sdk.AgentResponseTypeComplete, Done: true},
|
||||
}
|
||||
var got []sse.ProjectedEvent
|
||||
for _, event := range input {
|
||||
if projected, ok := p.Agent(event); ok {
|
||||
got = append(got, projected)
|
||||
}
|
||||
}
|
||||
want := []string{"thinking", "tool_call", "references", "answer", "complete"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("events=%+v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i].ResponseType != want[i] {
|
||||
t.Errorf("event[%d]=%q, want %q", i, got[i].ResponseType, want[i])
|
||||
}
|
||||
}
|
||||
refs := got[2].KnowledgeReferences
|
||||
if len(refs) != 1 || refs[0].KBID != "kb1" || refs[0].ChunkID != "c1" || refs[0].ParentChunkID != "p1" {
|
||||
t.Errorf("reference index=%+v", refs)
|
||||
}
|
||||
if input[2].KnowledgeReferences[0].Content != "bulk" {
|
||||
t.Error("projection mutated the raw SDK event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjector_ReferenceOnlyAddsIndexesWithoutExecutionTrace(t *testing.T) {
|
||||
p := sse.NewProjector(false, true, "kb_fallback")
|
||||
input := []*sdk.AgentStreamResponse{
|
||||
{ID: "think", ResponseType: sdk.AgentResponseTypeThinking, Content: "hidden"},
|
||||
{ID: "refs", ResponseType: sdk.AgentResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{{ID: "c1", KnowledgeBaseID: "kb1", Content: "bulk"}}},
|
||||
{ID: "answer", ResponseType: sdk.AgentResponseTypeAnswer, Content: "answer"},
|
||||
{ResponseType: sdk.AgentResponseTypeComplete, Done: true},
|
||||
}
|
||||
var got []sse.ProjectedEvent
|
||||
for _, event := range input {
|
||||
if projected, ok := p.Agent(event); ok {
|
||||
got = append(got, projected)
|
||||
}
|
||||
}
|
||||
if len(got) != 2 || got[0].ResponseType != "references" || got[1].ResponseType != "answer" {
|
||||
t.Fatalf("reference-only events=%+v", got)
|
||||
}
|
||||
if len(got[0].KnowledgeReferences) != 1 || got[0].KnowledgeReferences[0].ChunkID != "c1" {
|
||||
t.Errorf("reference indexes=%+v", got[0].KnowledgeReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjector_VerboseDoesNotImplicitlyAddReferences(t *testing.T) {
|
||||
p := sse.NewProjector(true, false, "kb")
|
||||
input := []*sdk.AgentStreamResponse{
|
||||
{ResponseType: sdk.AgentResponseTypeThinking, Content: "thinking"},
|
||||
{ResponseType: sdk.AgentResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{{ID: "c1"}}},
|
||||
{ResponseType: sdk.AgentResponseTypeAnswer, Content: "answer"},
|
||||
{ResponseType: sdk.AgentResponseTypeComplete, Done: true},
|
||||
}
|
||||
var got []sse.ProjectedEvent
|
||||
for _, event := range input {
|
||||
if projected, ok := p.Agent(event); ok {
|
||||
got = append(got, projected)
|
||||
}
|
||||
}
|
||||
for _, event := range got {
|
||||
if event.ResponseType == "references" || len(event.KnowledgeReferences) != 0 {
|
||||
t.Fatalf("verbose leaked references without reference=true: %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjector_TerminalErrorMarksDone(t *testing.T) {
|
||||
p := sse.NewProjector(false, false, "kb")
|
||||
_, include := p.Chat(&sdk.StreamResponse{
|
||||
ResponseType: sdk.ResponseTypeAnswer,
|
||||
Content: "partial",
|
||||
})
|
||||
if !include || p.Done() {
|
||||
t.Fatalf("answer frame: include=%v done=%v", include, p.Done())
|
||||
}
|
||||
_, include = p.Chat(&sdk.StreamResponse{
|
||||
ResponseType: sdk.ResponseTypeError,
|
||||
Content: "boom",
|
||||
Done: true,
|
||||
})
|
||||
if include {
|
||||
t.Fatal("default projection should not include terminal error event")
|
||||
}
|
||||
if !p.Done() {
|
||||
t.Fatal("terminal error did not mark projector done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextRenderer_StreamsSelectedEvents(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
r := sse.NewTextRenderer(&out, true)
|
||||
events := []sse.ProjectedEvent{
|
||||
{ID: "t", ResponseType: "thinking", Content: "THINK", Done: true},
|
||||
{ID: "call", ResponseType: "tool_call", Content: "SEARCH"},
|
||||
{ID: "a", ResponseType: "answer", Content: "ANSWER", Done: true},
|
||||
}
|
||||
for _, event := range events {
|
||||
if err := r.Write(event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
thinking := strings.Index(got, "THINK")
|
||||
tool := strings.Index(got, "SEARCH")
|
||||
answer := strings.Index(got, "ANSWER")
|
||||
if !(thinking >= 0 && thinking < tool && tool < answer) {
|
||||
t.Errorf("event order changed: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
)
|
||||
|
||||
// ConfirmPrompter is a test double for prompt.Prompter that scripts a single
|
||||
// Confirm answer (with optional error). Input/Password are stubbed to return
|
||||
// prompt.ErrAgentNoPrompt - assert it via `Asked` after the call.
|
||||
//
|
||||
// Use across cmd/* tests where a command's confirm-prompt branch needs to be
|
||||
// exercised. Avoid maintaining per-command copies of the same shape.
|
||||
type ConfirmPrompter struct {
|
||||
Answer bool
|
||||
Err error
|
||||
Asked bool
|
||||
}
|
||||
|
||||
func (c *ConfirmPrompter) Input(string, string) (string, error) {
|
||||
return "", prompt.ErrAgentNoPrompt
|
||||
}
|
||||
|
||||
func (c *ConfirmPrompter) Password(string) (string, error) {
|
||||
return "", prompt.ErrAgentNoPrompt
|
||||
}
|
||||
|
||||
func (c *ConfirmPrompter) Confirm(string, bool) (bool, error) {
|
||||
c.Asked = true
|
||||
return c.Answer, c.Err
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Package testutil holds small test-only helpers shared across cli/internal packages.
|
||||
//
|
||||
// Build tag is intentionally absent: Go links _test.go files only into test
|
||||
// binaries, but other packages need to import this from their tests. Keep
|
||||
// production code out.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// XDGTempDir creates a fresh temp dir and points XDG_CONFIG_HOME at it for the
|
||||
// duration of t. Use whenever a test exercises code that reads
|
||||
// $XDG_CONFIG_HOME/weknora/* (config.Load, secrets.NewFileStore, ...).
|
||||
func XDGTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
return dir
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package text
|
||||
|
||||
// KnowledgeDisplayName picks the most informative human label for a
|
||||
// knowledge entry. File-uploads use FileName; URL / text entries fall
|
||||
// back to Title; the ID is the last-resort placeholder so a table cell
|
||||
// is never empty. Single source for the ordering - `weknora doc list`
|
||||
// and `weknora search docs` both call this so a Knowledge renders
|
||||
// identically in either command.
|
||||
//
|
||||
// Takes the three fields directly rather than `sdk.Knowledge` so this
|
||||
// package stays free of SDK imports.
|
||||
func KnowledgeDisplayName(fileName, title, id string) string {
|
||||
if fileName != "" {
|
||||
return fileName
|
||||
}
|
||||
if title != "" {
|
||||
return title
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package text
|
||||
|
||||
import "strings"
|
||||
|
||||
// ContainsFold reports whether any of fields contains needle, comparing
|
||||
// case-insensitively. Avoids the inline `strings.Contains(strings.ToLower
|
||||
// (field), needle)` triple-pattern when callers want to OR-match across
|
||||
// several columns of the same record. The caller passes the needle in
|
||||
// lowercase form so we don't lowercase the same string per call site.
|
||||
func ContainsFold(lowerNeedle string, fields ...string) bool {
|
||||
for _, f := range fields {
|
||||
if strings.Contains(strings.ToLower(f), lowerNeedle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package text
|
||||
|
||||
import "strings"
|
||||
|
||||
// OneLine collapses newlines/carriage-returns/tabs in s to single spaces,
|
||||
// then truncates to maxDisplayWidth columns (UTF-8 safe via Truncate).
|
||||
// Use for human-readable preview rows where multiline content would
|
||||
// break tabular layout.
|
||||
func OneLine(maxDisplayWidth int, s string) string {
|
||||
return Truncate(maxDisplayWidth, lineReplacer.Replace(s))
|
||||
}
|
||||
|
||||
var lineReplacer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ")
|
||||
@@ -0,0 +1,29 @@
|
||||
package text_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
)
|
||||
|
||||
func TestOneLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
max int
|
||||
in, want string
|
||||
}{
|
||||
{"empty", 10, "", ""},
|
||||
{"no-collapse", 10, "hello", "hello"},
|
||||
{"collapse-newline", 20, "hello\nworld", "hello world"},
|
||||
{"collapse-cr-tab", 20, "hello\r\tworld", "hello world"},
|
||||
{"truncate", 8, "hello world long", "hello w…"},
|
||||
{"truncate-after-collapse", 8, "hello\nworld\nlong", "hello w…"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := text.OneLine(c.max, c.in); got != c.want {
|
||||
t.Errorf("OneLine(%d, %q) = %q, want %q", c.max, c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Package text holds string helpers for human-readable CLI output.
|
||||
// All functions are pure (no I/O, no time.Now), making them trivially testable.
|
||||
package text
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Pluralize returns "<n> <thing>" or "<n> <thing>s". Simple suffix "s" only;
|
||||
// irregular forms (person/people) are not supported.
|
||||
func Pluralize(n int, thing string) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprintf("%d %s", n, thing)
|
||||
}
|
||||
return fmt.Sprintf("%d %ss", n, thing)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package text_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
)
|
||||
|
||||
func TestPluralize(t *testing.T) {
|
||||
tests := []struct {
|
||||
n int
|
||||
thing string
|
||||
want string
|
||||
}{
|
||||
{0, "doc", "0 docs"},
|
||||
{1, "doc", "1 doc"},
|
||||
{2, "doc", "2 docs"},
|
||||
{5, "chunk", "5 chunks"},
|
||||
{1, "chunk", "1 chunk"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := text.Pluralize(tt.n, tt.thing)
|
||||
if got != tt.want {
|
||||
t.Errorf("Pluralize(%d, %q) = %q, want %q", tt.n, tt.thing, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package text
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FuzzyAgo formats t relative to now as a short relative-time string.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// FuzzyAgo(now, now-5m) → "about 5 minutes ago"
|
||||
// FuzzyAgo(now, now-3h) → "about 3 hours ago"
|
||||
// FuzzyAgo(now, now-2d) → "about 2 days ago"
|
||||
// FuzzyAgo(now, now+5m) → "about 5 minutes from now"
|
||||
func FuzzyAgo(now, t time.Time) string {
|
||||
d := now.Sub(t)
|
||||
suffix := "ago"
|
||||
if d < 0 {
|
||||
d = -d
|
||||
suffix = "from now"
|
||||
}
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "less than a minute " + suffix
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Minutes())), "minute"), suffix)
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours())), "hour"), suffix)
|
||||
case d < 30*24*time.Hour:
|
||||
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours()/24)), "day"), suffix)
|
||||
case d < 365*24*time.Hour:
|
||||
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours()/(24*30))), "month"), suffix)
|
||||
default:
|
||||
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours()/(24*365))), "year"), suffix)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user