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

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