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
+161
View File
@@ -0,0 +1,161 @@
package kb
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// CheckResult is the deep-verification response for `kb check <id>`.
// Superset of StatusResult: includes all status fields plus FailedCount
// aggregated by paging the doc list. Verb split with `kb status`:
// status reads existing state cheaply, check actively verifies.
type CheckResult struct {
ID string `json:"id"`
Reachable bool `json:"reachable"`
// RetrievalReady is false when no embedding model is bound — the KB can never
// index/retrieve regardless of failed_count. Always emitted (no omitempty) so
// an unconfigured KB is not reported as silently healthy.
RetrievalReady bool `json:"retrieval_ready"`
KnowledgeCount int64 `json:"knowledge_count,omitempty"`
ChunkCount int64 `json:"chunk_count,omitempty"`
IsProcessing bool `json:"is_processing,omitempty"`
ProcessingCount int64 `json:"processing_count,omitempty"`
FailedCount int64 `json:"failed_count"` // always populated (no omitempty)
}
// CheckService is the narrow SDK surface needed for kb check.
type CheckService interface {
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
ListKnowledgeWithFilter(ctx context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error)
}
var kbCheckFields = []string{
"id", "reachable", "retrieval_ready", "knowledge_count", "chunk_count",
"is_processing", "processing_count", "failed_count",
}
// NewCmdCheck builds `weknora kb check <id>`.
func NewCmdCheck(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "check <kb-id>",
Short: "Verify a knowledge base end-to-end (status + failed-doc aggregation)",
Long: `Active verification of a knowledge base.
Performs 1 + N HTTP calls:
1 GET /kb/{id} — reachable + counts + processing state
N page-walk doc list with parse_status=failed — failed_count
Use 'weknora kb status <id>' for a fast read-only health snapshot
(1 HTTP call, no failed_count). Use 'kb check' when you need
verification including failed-doc aggregation.`,
Example: ` weknora kb check kb_abc
weknora kb check kb_abc --format json`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
res, err := runCheck(c.Context(), cli, args[0])
if err != nil {
return err
}
return emitCheck(res, fopts, iostreams.IO.Out)
},
}
cmdutil.AddFormatFlag(cmd, kbCheckFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "verify a knowledge base end-to-end: status plus failed-doc aggregation",
RequiredFlags: []string{"<kb-id> (positional)"},
Examples: []string{"weknora kb check kb_abc"},
Output: "envelope.data is {id, reachable, retrieval_ready, failed_count, ...}; retrieval_ready=false means no embedding model is bound (run `kb config set`); deeper than `kb status`",
})
return cmd
}
// runCheck is the testable core: status fields + failed-doc aggregation.
// Never returns an error for "kb not reachable" (Reachable=false carries
// the signal).
func runCheck(ctx context.Context, svc CheckService, id string) (*CheckResult, error) {
kb, err := svc.GetKnowledgeBase(ctx, id)
if err != nil {
return &CheckResult{ID: id, Reachable: false}, nil
}
res := &CheckResult{
ID: kb.ID,
Reachable: true,
RetrievalReady: kb.EmbeddingModelID != "",
KnowledgeCount: kb.KnowledgeCount,
ChunkCount: kb.ChunkCount,
IsProcessing: kb.IsProcessing,
ProcessingCount: kb.ProcessingCount,
}
failed, err := aggregateFailedCount(ctx, svc, id)
if err != nil {
return res, fmt.Errorf("aggregate failed_count: %w", err)
}
res.FailedCount = failed
return res, nil
}
// aggregateFailedCount pages the doc list with parse_status=failed and
// returns the total count. Termination uses accumulated total (not
// page*pageSize) so server-capped page sizes don't truncate.
func aggregateFailedCount(ctx context.Context, svc CheckService, kbID string) (int64, error) {
const pageSize = 100
var total int64
page := 1
for {
docs, totalAvailable, err := svc.ListKnowledgeWithFilter(ctx, kbID, page, pageSize, sdk.KnowledgeListFilter{ParseStatus: "failed"})
if err != nil {
return total, err
}
total += int64(len(docs))
if total >= totalAvailable || len(docs) == 0 {
break
}
page++
}
return total, nil
}
// emitCheck renders res. Same dispatch as emitStatus.
func emitCheck(res *CheckResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
switch fopts.Mode {
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
return fopts.Emit(w, res, nil)
case cmdutil.FormatText, "":
return writeCheckText(w, res)
default:
return fmt.Errorf("unsupported --format %q for kb check", fopts.Mode)
}
}
func writeCheckText(w io.Writer, res *CheckResult) error {
fmt.Fprintf(w, "ID: %s\n", res.ID)
fmt.Fprintf(w, "Reachable: %v\n", res.Reachable)
if !res.Reachable {
return nil
}
fmt.Fprintf(w, "Retrieval: %v%s\n", res.RetrievalReady, retrievalHint(res.RetrievalReady))
fmt.Fprintf(w, "Knowledge: %d\n", res.KnowledgeCount)
fmt.Fprintf(w, "Chunks: %d\n", res.ChunkCount)
fmt.Fprintf(w, "Processing: %v (%d active)\n", res.IsProcessing, res.ProcessingCount)
fmt.Fprintf(w, "Failed: %d\n", res.FailedCount)
return nil
}
// compile-time check: SDK client satisfies CheckService.
var _ CheckService = (*sdk.Client)(nil)
+135
View File
@@ -0,0 +1,135 @@
package kb
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeCheckSvc struct {
kb *sdk.KnowledgeBase
getErr error
failedDocs []sdk.Knowledge // returned by ListKnowledgeWithFilter when ParseStatus=failed
listErr error
}
func (f *fakeCheckSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
if f.getErr != nil {
return nil, f.getErr
}
return f.kb, nil
}
func (f *fakeCheckSvc) ListKnowledgeWithFilter(_ context.Context, _ string, page, pageSize int, _ sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) {
if f.listErr != nil {
return nil, 0, f.listErr
}
// Single-page fake: return all docs at once (page 1), empty thereafter.
if page == 1 {
return f.failedDocs, int64(len(f.failedDocs)), nil
}
return nil, int64(len(f.failedDocs)), nil
}
func TestRunCheck_AggregatesFailed(t *testing.T) {
svc := &fakeCheckSvc{
kb: &sdk.KnowledgeBase{ID: "kb_x", KnowledgeCount: 5, ChunkCount: 20, EmbeddingModelID: "emb_1"},
failedDocs: []sdk.Knowledge{
{ID: "d1", ParseStatus: "failed"},
{ID: "d2", ParseStatus: "failed"},
},
}
res, err := runCheck(context.Background(), svc, "kb_x")
if err != nil {
t.Fatalf("runCheck: %v", err)
}
if res.FailedCount != 2 {
t.Errorf("FailedCount=%d, want 2", res.FailedCount)
}
if !res.Reachable {
t.Error("Reachable=false, want true")
}
if !res.RetrievalReady {
t.Error("RetrievalReady=false, want true when an embedding model is bound")
}
if res.KnowledgeCount != 5 || res.ChunkCount != 20 {
t.Errorf("got %+v", res)
}
}
func TestRunCheck_Unreachable(t *testing.T) {
svc := &fakeCheckSvc{getErr: fmt.Errorf("404 not found")}
res, err := runCheck(context.Background(), svc, "kb_x")
if err != nil {
t.Fatalf("runCheck should not return err on unreachable; got %v", err)
}
if res.Reachable {
t.Error("Reachable=true, want false")
}
if res.ID != "kb_x" {
t.Errorf("ID=%q, want kb_x (echoed even when unreachable)", res.ID)
}
}
func TestRunCheck_NoFailedDocs(t *testing.T) {
svc := &fakeCheckSvc{
kb: &sdk.KnowledgeBase{ID: "kb_y", KnowledgeCount: 10},
failedDocs: nil,
}
res, err := runCheck(context.Background(), svc, "kb_y")
if err != nil {
t.Fatalf("runCheck: %v", err)
}
if res.FailedCount != 0 {
t.Errorf("FailedCount=%d, want 0", res.FailedCount)
}
}
func TestEmitCheck_JSON(t *testing.T) {
var buf bytes.Buffer
res := &CheckResult{ID: "kb_x", Reachable: true, KnowledgeCount: 5, FailedCount: 3}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := emitCheck(res, fopts, &buf); err != nil {
t.Fatalf("emitCheck: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data CheckResult `json:"data"`
}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("not JSON: %v\n%s", err, buf.String())
}
got := env.Data
if got.ID != "kb_x" || got.KnowledgeCount != 5 || got.FailedCount != 3 {
t.Errorf("got %+v", got)
}
}
func TestEmitCheck_Text(t *testing.T) {
var buf bytes.Buffer
res := &CheckResult{
ID: "kb_x", Reachable: true,
KnowledgeCount: 5, ChunkCount: 20,
IsProcessing: true, ProcessingCount: 1,
FailedCount: 2,
}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
if err := emitCheck(res, fopts, &buf); err != nil {
t.Fatalf("emitCheck: %v", err)
}
for _, want := range []string{"kb_x", "5", "20", "true", "2"} {
if !strings.Contains(buf.String(), want) {
t.Errorf("output missing %q:\n%s", want, buf.String())
}
}
// "Failed:" line must always appear for check
if !strings.Contains(buf.String(), "Failed:") {
t.Errorf("output missing 'Failed:' line:\n%s", buf.String())
}
}
+110
View File
@@ -0,0 +1,110 @@
package kb
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// kbConfigFields enumerates the fields surfaced for `--format json` discovery on
// `kb config`. Mirrors client.KBModelConfigView (secret-free — no api keys).
var kbConfigFields = []string{
"retrieval_ready", "embedding", "llm", "rerank", "multimodal",
}
// ConfigService is the narrow SDK surface this command depends on.
type ConfigService interface {
GetInitializationConfig(ctx context.Context, kbID string) (*sdk.KBModelConfigView, error)
}
// NewCmdConfig builds `weknora kb config <kb-id>` — read-only inspection of a
// knowledge base's model configuration. Write it with `weknora kb config set`.
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config <kb-id>",
Short: "Show a knowledge base's model configuration (set it with `config set`)",
Long: `Show the model configuration bound to a knowledge base: embedding, llm
(chat), rerank, and multimodal model names + source. retrieval_ready is false
until an embedding model is bound — configure it with 'weknora kb config set'.
Provider API keys are never shown.`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runConfig(c.Context(), fopts, cli, args[0])
},
}
// `kb config` reads (this command's RunE); `kb config set` writes. Same
// read/write pairing as mainstream config surfaces.
cmd.AddCommand(newKBModelWriteCmd(f, "set <kb-id>", []string{"weknora", "kb", "config", "set"}))
cmdutil.AddFormatFlag(cmd, kbConfigFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "show a KB's model config (embedding/llm/rerank/multimodal by name, secret-free). retrieval_ready=false => run `weknora kb config set`. Write config with `weknora kb config set`.",
RequiredFlags: []string{"<kb-id> (positional)"},
Examples: []string{"weknora kb config kb_abc --jq .data.embedding_model_id"},
Output: "envelope.data is {retrieval_ready, embedding{configured,model_name,source,dimension}, llm{...}, rerank{enabled,model_name}, multimodal{enabled}} — secret-free (no provider api keys)",
})
return cmd
}
func runConfig(ctx context.Context, fopts *cmdutil.FormatOptions, svc ConfigService, kbID string) error {
cfg, err := svc.GetInitializationConfig(ctx, kbID)
if err != nil {
return cmdutil.WrapHTTP(err, "get config for knowledge base %q", kbID)
}
if cfg == nil {
cfg = &sdk.KBModelConfigView{}
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, cfg, nil)
}
w := iostreams.IO.Out
fmt.Fprintf(w, "%-13s %v\n", "RETRIEVAL:", readyLabel(cfg.RetrievalReady))
fmt.Fprintf(w, "%-13s %s\n", "EMBEDDING:", slotLabel(cfg.Embedding))
fmt.Fprintf(w, "%-13s %s\n", "CHAT (LLM):", slotLabel(cfg.LLM))
fmt.Fprintf(w, "%-13s %s\n", "RERANK:", rerankLabel(cfg.Rerank))
fmt.Fprintf(w, "%-13s %v\n", "MULTIMODAL:", cfg.Multimodal.Enabled)
if !cfg.RetrievalReady {
fmt.Fprintln(w, "\n(not retrieval-ready — no embedding model; run `weknora kb config set <kb-id> --chat-model <id> --embedding-model <id>`)")
}
return nil
}
func readyLabel(ready bool) string {
if ready {
return "ready"
}
return "NOT ready (no embedding model)"
}
func slotLabel(s sdk.ModelSlotView) string {
if !s.Configured {
return "(unset)"
}
if s.Source != "" {
return fmt.Sprintf("%s (%s)", s.ModelName, s.Source)
}
return s.ModelName
}
func rerankLabel(r sdk.RerankSlotView) string {
if !r.Enabled {
return "(disabled)"
}
return r.ModelName
}
// compile-time check: the production SDK client implements ConfigService.
var _ ConfigService = (*sdk.Client)(nil)
+166
View File
@@ -0,0 +1,166 @@
package kb
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type ConfigSetOptions struct {
ChatModel string
EmbeddingModel string
Yes bool
DryRun bool
}
// ConfigSetService is the narrow SDK surface this command depends on. SetKBModelConfig
// points the KB at already-registered models; GetInitializationConfig re-reads
// the server's resulting state so the success envelope reflects what stuck.
type ConfigSetService interface {
SetKBModelConfig(ctx context.Context, kbID string, cfg *sdk.KBModelConfig) error
GetInitializationConfig(ctx context.Context, kbID string) (*sdk.KBModelConfigView, error)
}
// newKBModelWriteCmd builds the `kb config set` model-binding write command.
// head is the argv prefix used for the risk action and retry_argv (weknora kb
// config set).
func newKBModelWriteCmd(f *cmdutil.Factory, use string, head []string) *cobra.Command {
opts := &ConfigSetOptions{}
action := strings.Join(head[1:], ".") // e.g. "kb.config.set"
cmd := &cobra.Command{
Use: use,
Short: "Bind embedding + chat models to a knowledge base (make it usable)",
Long: `Bind already-registered models to a knowledge base so it can embed, retrieve,
and generate. Both --chat-model (LLM, used for generation/summary) and
--embedding-model (used for retrieval) are required; register models first with
'weknora model create' and discover ids with 'weknora model list'.
High-risk write: changing a KB's embedding model affects how its content is
indexed and searched (the server refuses to CHANGE it once the KB has
documents; setting it on an unconfigured KB is allowed). Without -y/--yes in a
non-TTY / JSON context it exits 10 (input.confirmation_required) without
applying the change.`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Yes, _ = c.Flags().GetBool("yes")
kbID := args[0]
// Validate required flags before the dry-run gate so --dry-run rejects
// identically to the live path.
if err := validateConfigSetFlags(opts); err != nil {
return err
}
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: action,
Args: map[string]any{"kb": kbID, "chat_model": opts.ChatModel, "embedding_model": opts.EmbeddingModel},
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(),
"configure", "knowledge base", kbID, action,
cmdutil.BuildRetryArgv(c, append(append([]string{}, head...), kbID), "chat-model", "embedding-model", "format")); err != nil {
return err
}
// Resolve name-or-id for the model flags (a UUID passes through; a
// name is looked up among models of the expected type). Network read
// on the live path only — the dry-run above shows the raw refs.
if opts.ChatModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.ChatModel, "KnowledgeQA"); err != nil {
return err
}
if opts.EmbeddingModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.EmbeddingModel, "Embedding"); err != nil {
return err
}
return runConfigSet(c.Context(), opts, fopts, cli, kbID)
},
}
cmd.Flags().StringVar(&opts.ChatModel, "chat-model", "", "Chat / LLM model id or name for generation & summary (required) — see `weknora model list`")
cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model id or name for retrieval (required) — see `weknora model list`")
cmdutil.AddFormatFlag(cmd, kbConfigFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetRisk(cmd, action)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "bind models to a KB so it becomes retrieval-ready. --chat-model and --embedding-model are required and accept a model id or name; discover them with `weknora model list`. Read the result back with `weknora kb config`.",
RequiredFlags: []string{"<kb-id> (positional)", "--chat-model", "--embedding-model"},
Examples: []string{
"weknora kb config set kb_abc --chat-model model_llm --embedding-model model_emb -y",
},
Output: "envelope.data is the resulting secret-free config view {retrieval_ready, embedding, llm, rerank, multimodal}",
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
"The server refuses to CHANGE the embedding model of a KB that already has documents (setting it on an unconfigured KB is fine).",
},
})
return cmd
}
func validateConfigSetFlags(opts *ConfigSetOptions) error {
var missing []string
if strings.TrimSpace(opts.ChatModel) == "" {
missing = append(missing, "--chat-model")
}
if strings.TrimSpace(opts.EmbeddingModel) == "" {
missing = append(missing, "--embedding-model")
}
if len(missing) == 0 {
return nil
}
return &cmdutil.Error{
Code: cmdutil.CodeInputMissingFlag,
Message: "kb config set requires " + strings.Join(missing, " and "),
Hint: "discover model ids with `weknora model list` (or register one with `weknora model create`), then pass --chat-model <id> --embedding-model <id>",
}
}
func runConfigSet(ctx context.Context, opts *ConfigSetOptions, fopts *cmdutil.FormatOptions, svc ConfigSetService, kbID string) error {
if err := validateConfigSetFlags(opts); err != nil {
return err
}
cfg := &sdk.KBModelConfig{
LLMModelID: opts.ChatModel,
EmbeddingModelID: opts.EmbeddingModel,
}
if err := svc.SetKBModelConfig(ctx, kbID, cfg); err != nil {
return cmdutil.WrapHTTP(err, "configure knowledge base %q", kbID)
}
// Re-read the server's resulting state (secret-free view) so the envelope
// reflects what stuck — the same shape `kb config` returns.
result, err := svc.GetInitializationConfig(ctx, kbID)
if err != nil || result == nil {
// The write succeeded; surface what we applied if the read-back failed.
result = &sdk.KBModelConfigView{
RetrievalReady: opts.EmbeddingModel != "",
Embedding: sdk.ModelSlotView{Configured: opts.EmbeddingModel != "", ModelName: opts.EmbeddingModel},
LLM: sdk.ModelSlotView{Configured: opts.ChatModel != "", ModelName: opts.ChatModel},
}
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, result, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Configured knowledge base %s (chat: %s, embedding: %s)\n",
kbID, orUnset(result.LLM.ModelName), orUnset(result.Embedding.ModelName))
return nil
}
func orUnset(s string) string {
if s == "" {
return "(unset)"
}
return s
}
// compile-time check: the production SDK client implements ConfigSetService.
var _ ConfigSetService = (*sdk.Client)(nil)
+138
View File
@@ -0,0 +1,138 @@
package kb
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeInitSvc struct {
gotKB string
gotCfg *sdk.KBModelConfig
result *sdk.KBModelConfigView
setErr error
}
func (f *fakeInitSvc) SetKBModelConfig(_ context.Context, kbID string, cfg *sdk.KBModelConfig) error {
f.gotKB = kbID
f.gotCfg = cfg
return f.setErr
}
func (f *fakeInitSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.KBModelConfigView, error) {
if f.result != nil {
return f.result, nil
}
if f.gotCfg == nil {
return &sdk.KBModelConfigView{}, nil
}
return &sdk.KBModelConfigView{
RetrievalReady: f.gotCfg.EmbeddingModelID != "",
Embedding: sdk.ModelSlotView{Configured: f.gotCfg.EmbeddingModelID != "", ModelName: f.gotCfg.EmbeddingModelID},
LLM: sdk.ModelSlotView{Configured: f.gotCfg.LLMModelID != "", ModelName: f.gotCfg.LLMModelID},
}, nil
}
func TestKBConfigSet_AppliesAndEmitsResult(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeInitSvc{}
opts := &ConfigSetOptions{ChatModel: "model_llm", EmbeddingModel: "model_emb"}
require.NoError(t, runConfigSet(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
assert.Equal(t, "kb_abc", svc.gotKB)
require.NotNil(t, svc.gotCfg)
assert.Equal(t, "model_llm", svc.gotCfg.LLMModelID)
assert.Equal(t, "model_emb", svc.gotCfg.EmbeddingModelID)
var env struct {
OK bool `json:"ok"`
Data sdk.KBModelConfigView `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.True(t, env.OK)
assert.Equal(t, "model_emb", env.Data.Embedding.ModelName)
assert.Equal(t, "model_llm", env.Data.LLM.ModelName)
assert.True(t, env.Data.RetrievalReady)
}
func TestKBConfigSet_RequiresBothModels(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeInitSvc{}
// Missing both.
err := runConfigSet(context.Background(), &ConfigSetOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")
var ce *cmdutil.Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, cmdutil.CodeInputMissingFlag, ce.Code)
assert.Equal(t, "", svc.gotKB, "must not call SetKBModelConfig when flags are missing")
// Missing just embedding.
err = runConfigSet(context.Background(), &ConfigSetOptions{ChatModel: "m"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")
require.ErrorAs(t, err, &ce)
assert.Contains(t, ce.Message, "--embedding-model")
}
func TestKBConfigSet_WriteSucceedsReadbackFails(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc2 := &readbackErrSvc{fakeInitSvc: &fakeInitSvc{}}
opts := &ConfigSetOptions{ChatModel: "model_llm", EmbeddingModel: "model_emb"}
require.NoError(t, runConfigSet(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc2, "kb_abc"))
var env struct {
Data sdk.KBModelConfigView `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.Equal(t, "model_emb", env.Data.Embedding.ModelName, "falls back to applied config when read-back fails")
}
type readbackErrSvc struct{ *fakeInitSvc }
func (s *readbackErrSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.KBModelConfigView, error) {
return nil, errors.New("read-back boom")
}
// withRootKB wraps a kb subcommand under a synthetic root with global flags,
// keyed off the sub's own name (unlike withRootHarnessKB which is hardcoded to
// `update`).
func withRootKB(sub *cobra.Command, args ...string) *cobra.Command {
root := &cobra.Command{Use: "weknora"}
pf := root.PersistentFlags()
pf.BoolP("yes", "y", false, "")
pf.String("format", "", "")
pf.StringP("jq", "q", "", "")
kb := &cobra.Command{Use: "kb"}
kb.AddCommand(sub)
root.AddCommand(kb)
root.SetArgs(append([]string{"kb", sub.Name()}, args...))
root.SetContext(context.Background())
root.SilenceErrors = true
root.SilenceUsage = true
return root
}
func TestKBConfigSet_RequiresConfirmation(t *testing.T) {
iostreams.SetForTest(t)
f := &cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, nil },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
}
// Drive `kb config set` (the config parent routes to its `set` subcommand).
root := withRootKB(NewCmdConfig(f), "set", "kb_abc", "--chat-model", "model_llm", "--embedding-model", "model_emb", "--format", "json")
err := root.Execute()
require.Error(t, err)
var ce *cmdutil.Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
assert.Equal(t, 10, cmdutil.ExitCode(err))
assert.Contains(t, ce.RetryArgv, "-y")
assert.Contains(t, ce.RetryArgv, "model_emb")
assert.Contains(t, ce.RetryArgv, "set", "retry_argv should target `kb config set`")
}
+70
View File
@@ -0,0 +1,70 @@
package kb
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeConfigSvc struct {
cfg *sdk.KBModelConfigView
err error
}
func (f *fakeConfigSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.KBModelConfigView, error) {
return f.cfg, f.err
}
func TestKBConfig_EmitsConfig(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeConfigSvc{cfg: &sdk.KBModelConfigView{
RetrievalReady: true,
Embedding: sdk.ModelSlotView{Configured: true, ModelName: "model_emb", Source: "remote"},
LLM: sdk.ModelSlotView{Configured: true, ModelName: "model_chat"},
}}
require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
var env struct {
OK bool `json:"ok"`
Data sdk.KBModelConfigView `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.True(t, env.OK)
assert.Equal(t, "model_emb", env.Data.Embedding.ModelName)
assert.Equal(t, "model_chat", env.Data.LLM.ModelName)
assert.True(t, env.Data.RetrievalReady)
}
// TestKBConfig_SecretFree: the view type has no apiKey/baseUrl field, so the
// JSON output can never carry provider credentials — the CLI never echoes the
// keys the server returns for the web config form.
func TestKBConfig_SecretFree(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeConfigSvc{cfg: &sdk.KBModelConfigView{
Embedding: sdk.ModelSlotView{Configured: true, ModelName: "e", Source: "remote"},
}}
require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
assert.NotContains(t, out.String(), "apiKey")
assert.NotContains(t, out.String(), "api_key")
assert.NotContains(t, out.String(), "baseUrl")
}
// TestKBConfig_NilConfig: a nil server config (KB not yet initialized) emits
// retrieval_ready:false, not a crash.
func TestKBConfig_NilConfig(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeConfigSvc{cfg: nil}
require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
var env struct {
Data sdk.KBModelConfigView `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.False(t, env.Data.RetrievalReady)
assert.Empty(t, env.Data.Embedding.ModelName)
}
+169
View File
@@ -0,0 +1,169 @@
package kb
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
sdk "github.com/Tencent/WeKnora/client"
)
// kbCreateFields enumerates the fields surfaced for `--format json` discovery
// on `kb create`. The result is the full KnowledgeBase struct; these mirror
// its top-level json tags. Nested config objects are intentionally omitted —
// users wanting them can drop the projection or use --jq.
var kbCreateFields = []string{
"id", "name", "type", "description",
"is_temporary", "is_pinned",
"embedding_model_id", "summary_model_id",
"knowledge_count", "chunk_count",
"is_processing", "processing_count",
"created_at", "updated_at",
}
type CreateOptions struct {
Name string
Description string
EmbeddingModel string
ChatModel string
StorageProvider string
DryRun bool
}
// storageProviderValues mirrors the server enum in
// internal/types/knowledgebase.go:StorageProviderConfig.Provider.
var storageProviderValues = []string{"local", "minio", "cos", "tos", "s3", "oss", "ks3", "obs"}
// CreateService is the narrow SDK surface this command depends on.
// *sdk.Client satisfies it via duck typing.
type CreateService interface {
CreateKnowledgeBase(ctx context.Context, kb *sdk.KnowledgeBase) (*sdk.KnowledgeBase, error)
}
// NewCmdCreate builds `weknora kb create <name>`. Positional <name> only,
// consistent with `agent create <name>`.
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
opts := &CreateOptions{}
cmd := &cobra.Command{
Use: "create <name>",
Short: "Create a new knowledge base",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Name = args[0]
// Validate --storage-provider enum before the dry-run gate so
// --dry-run rejects identically to the live path. ValidateEnum
// returns input.invalid_argument (exit 5) and normalizes to the
// canonical lowercase form — consistent with every other enum flag
// (model --type, agent --agent-mode, message search --mode).
// runCreate re-validates for direct-call callers.
canonSP, err := cmdutil.ValidateEnum("storage-provider", opts.StorageProvider, storageProviderValues)
if err != nil {
return err
}
opts.StorageProvider = canonSP
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: "kb.create",
Args: map[string]any{
"name": opts.Name,
"description": opts.Description,
},
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
// --embedding-model accepts a model id or name (a UUID passes
// through; a name resolves among Embedding models). Configuring a
// KB's models fully is `weknora kb config set`; this just pre-sets the
// embedding model at creation.
if opts.EmbeddingModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.EmbeddingModel, "Embedding"); err != nil {
return err
}
// --chat-model (id or name) pre-sets the KB's LLM at creation, so a
// KB can be born retrieval-ready in one step. Full model config
// (rerank / multimodal) is still `weknora kb config set`.
if opts.ChatModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.ChatModel, "KnowledgeQA"); err != nil {
return err
}
return runCreate(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().StringVar(&opts.Description, "description", "", "Knowledge base description (optional)")
cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model id or name (optional; makes the KB retrieval-ready at creation)")
cmd.Flags().StringVar(&opts.ChatModel, "chat-model", "", "Chat/LLM model id or name (optional; pre-set the KB's answer model at creation)")
cmd.Flags().StringVar(&opts.StorageProvider, "storage-provider", "",
"Storage provider for documents in this KB: "+strings.Join(storageProviderValues, " | ")+" (optional; server default when unset)")
cmdutil.AddFormatFlag(cmd, kbCreateFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Create a new knowledge base with the given name. Emits the created KB object with its id.",
RequiredFlags: []string{"<name> (positional)"},
Examples: []string{
`weknora kb create "Eng Docs"`,
`weknora kb create "Eng Docs" --embedding-model text-embedding-3-small --chat-model gpt-4o-mini # retrieval-ready in one step`,
`weknora kb create "Eng Docs" --jq .data.id # capture id to chain into doc upload --kb`,
},
Output: "envelope.data is the created KnowledgeBase object with id, name, type, embedding_model_id, summary_model_id",
})
return cmd
}
func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOptions, svc CreateService) error {
// Trim defensively in case a caller invokes runCreate directly with
// whitespace; the cobra layer enforces a non-empty positional from the CLI.
if strings.TrimSpace(opts.Name) == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "knowledge base name is required")
}
req := &sdk.KnowledgeBase{
Name: opts.Name,
Description: opts.Description,
}
if opts.EmbeddingModel != "" {
req.EmbeddingModelID = opts.EmbeddingModel
}
if opts.ChatModel != "" {
req.SummaryModelID = opts.ChatModel
}
if opts.StorageProvider != "" {
canonSP, err := cmdutil.ValidateEnum("storage-provider", opts.StorageProvider, storageProviderValues)
if err != nil {
return err
}
req.StorageProviderConfig = &sdk.StorageProviderConfig{Provider: canonSP}
}
created, err := svc.CreateKnowledgeBase(ctx, req)
if err != nil {
return cmdutil.WrapHTTP(err, "create knowledge base")
}
// A KB with no embedding model can hold documents but never index/retrieve
// them — surface the next step at the point of creation instead of leaving
// the agent to discover a silent-draft KB via a later empty search.
var meta *output.Meta
if created.EmbeddingModelID == "" {
meta = &output.Meta{Hint: "retrieval_ready=false: no embedding model bound. Uploaded docs will not be searchable until you run `weknora kb config set " + created.ID + " --embedding-model <id> --chat-model <id>` (create the KB with --embedding-model/--chat-model to skip this step)."}
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, created, meta)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Created knowledge base %q (id: %s)\n", created.Name, created.ID)
if meta != nil {
fmt.Fprintf(iostreams.IO.Out, "⚠ %s\n", meta.Hint)
}
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package kb
import (
"context"
"encoding/json"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
sdk "github.com/Tencent/WeKnora/client"
)
// kbDryRunFactory builds a Factory whose Client closure panics if invoked —
// dry-run must early-exit before any SDK call. Prompter is similarly trapped:
// dry-run is non-interactive by contract.
func kbDryRunFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
return &cmdutil.Factory{
Client: func() (*sdk.Client, error) {
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
return nil, nil
},
Prompter: func() prompt.Prompter {
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
return nil
},
}
}
// withRootHarness wraps a kb command under a synthetic root cmd that
// registers the global persistent flags (mirrors addGlobalFlags in
// cmd/root.go). Required because kb subcommands inherit --yes / --format /
// --jq from root in production.
func withRootHarness(sub *cobra.Command, args ...string) *cobra.Command {
root := &cobra.Command{Use: "weknora"}
pf := root.PersistentFlags()
pf.BoolP("yes", "y", false, "")
pf.String("format", "", "")
pf.StringP("jq", "q", "", "")
root.AddCommand(sub)
root.SetArgs(append([]string{sub.Name()}, args...))
root.SetContext(context.Background())
root.SilenceErrors = true
root.SilenceUsage = true
return root
}
// TestKBCreate_DryRun_EmitsPlan: --dry-run on `kb create` must emit the
// standard dry-run envelope (ok:true, meta.dry_run:true, meta.plan.action) and
// must NOT touch the SDK. Verifies that the cobra-layer early-exit runs before
// f.Client() and that the plan shape matches the envelope contract.
func TestKBCreate_DryRun_EmitsPlan(t *testing.T) {
out, _ := iostreams.SetForTest(t)
f := kbDryRunFactory(t)
root := withRootHarness(NewCmdCreate(f),
"foo", "--description", "bar", "--dry-run", "--format", "json")
require.NoError(t, root.Execute(), "dry-run must succeed (exit 0) without SDK")
var env struct {
OK bool `json:"ok"`
Meta struct {
DryRun bool `json:"dry_run"`
Plan map[string]any `json:"plan"`
} `json:"meta"`
Data any `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
assert.True(t, env.OK, "envelope.ok must be true on dry-run success")
assert.True(t, env.Meta.DryRun, "meta.dry_run must be true")
assert.Equal(t, "kb.create", env.Meta.Plan["action"], "plan.action must be kb.create")
// plan.args contains the user-provided flags so agents can diff "what would happen".
planArgs, ok := env.Meta.Plan["args"].(map[string]any)
require.True(t, ok, "plan.args must be a map, got %T", env.Meta.Plan["args"])
assert.Equal(t, "foo", planArgs["name"], "plan.args.name must echo positional <name>")
assert.Equal(t, "bar", planArgs["description"], "plan.args.description must echo --description")
assert.Nil(t, env.Data, "data must be omitted on dry-run (no real result)")
}
// TestKBCreate_DryRun_RejectsInvalidStorageProvider: --dry-run must reject
// the same invalid --storage-provider value the live path rejects. Before
// the fix the enum check lived only in runCreate(), which HandleDryRun
// short-circuited past — so --dry-run silently accepted "garbage".
func TestKBCreate_DryRun_RejectsInvalidStorageProvider(t *testing.T) {
iostreams.SetForTest(t)
f := kbDryRunFactory(t)
root := withRootHarness(NewCmdCreate(f),
"foo", "--storage-provider", "garbage", "--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject invalid --storage-provider")
// The enum check returns input.invalid_argument (exit 5) — make sure the
// dry-run path preserves that exact mapping (same as the live path).
assert.Equal(t, 5, cmdutil.ExitCode(err), "invalid --storage-provider must map to exit 5")
}
+210
View File
@@ -0,0 +1,210 @@
package kb
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeCreateSvc captures the request and returns canned responses.
type fakeCreateSvc struct {
resp *sdk.KnowledgeBase
err error
got *sdk.KnowledgeBase
}
func (f *fakeCreateSvc) CreateKnowledgeBase(_ context.Context, kb *sdk.KnowledgeBase) (*sdk.KnowledgeBase, error) {
f.got = kb
return f.resp, f.err
}
func TestCreate_Success_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{
ID: "kb_new",
Name: "Marketing",
Description: "team docs",
EmbeddingModelID: "model_x",
}}
opts := &CreateOptions{
Name: "Marketing",
Description: "team docs",
EmbeddingModel: "model_x",
}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
// Body sent to SDK matches flags.
require.NotNil(t, svc.got)
assert.Equal(t, "Marketing", svc.got.Name)
assert.Equal(t, "team docs", svc.got.Description)
assert.Equal(t, "model_x", svc.got.EmbeddingModelID)
got := out.String()
for _, want := range []string{"✓", "Created", "Marketing", "kb_new"} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q in:\n%s", want, got)
}
}
}
func TestCreate_Success_OmitsEmbeddingModelWhenEmpty(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n"}}
opts := &CreateOptions{Name: "n"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
require.NotNil(t, svc.got)
assert.Equal(t, "", svc.got.EmbeddingModelID, "embedding-model unset ⇒ empty in request")
}
// A KB created without an embedding model can never retrieve; the create result
// must hand the agent the fix (kb config set) instead of a silent unusable KB.
func TestCreate_HintsWhenNoEmbeddingModel(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n"}}
require.NoError(t, runCreate(context.Background(), &CreateOptions{Name: "n"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
var env struct {
Meta struct {
Hint string `json:"hint"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.Contains(t, env.Meta.Hint, "kb config set", "unconfigured KB must hint the retrieval-readiness fix")
}
// A retrieval-ready KB (embedding model bound) carries no such hint — no noise.
func TestCreate_NoHintWhenEmbeddingModelBound(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n", EmbeddingModelID: "emb_1"}}
require.NoError(t, runCreate(context.Background(), &CreateOptions{Name: "n", EmbeddingModel: "emb_1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
var env struct {
Meta *struct {
Hint string `json:"hint"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
if env.Meta != nil {
assert.Empty(t, env.Meta.Hint, "retrieval-ready KB must not emit a readiness hint")
}
}
// TestCreate_ChatModelSetsSummaryModelID: --chat-model rides the create request
// as summary_model_id, so a KB can be born retrieval-ready in one step.
func TestCreate_ChatModelSetsSummaryModelID(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n"}}
opts := &CreateOptions{Name: "n", EmbeddingModel: "emb_x", ChatModel: "chat_x"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
require.NotNil(t, svc.got)
assert.Equal(t, "emb_x", svc.got.EmbeddingModelID)
assert.Equal(t, "chat_x", svc.got.SummaryModelID, "--chat-model must set summary_model_id on the create request")
}
func TestCreate_NameRequired(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{}
err := runCreate(context.Background(), &CreateOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
assert.Nil(t, svc.got, "service must not be called when name is missing")
}
func TestCreate_NameWhitespaceOnly(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{}
err := runCreate(context.Background(), &CreateOptions{Name: " "}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
func TestCreate_HTTPError_500(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{err: errors.New("HTTP error 500: internal")}
err := runCreate(context.Background(), &CreateOptions{Name: "x"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
func TestCreate_HTTPError_409Conflict(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{err: errors.New("HTTP error 409: name exists")}
err := runCreate(context.Background(), &CreateOptions{Name: "dup"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
}
func TestCreate_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_99", Name: "Eng"}}
opts := &CreateOptions{Name: "Eng"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data sdk.KnowledgeBase `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Equal(t, "kb_99", env.Data.ID, "envelope.data.id must be kb_99")
assert.Contains(t, got, `"name":"Eng"`)
}
func TestCreate_StorageProvider_InjectsRequest(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_s", Name: "n"}}
opts := &CreateOptions{Name: "n", StorageProvider: "Local"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
require.NotNil(t, svc.got.StorageProviderConfig)
assert.Equal(t, "local", svc.got.StorageProviderConfig.Provider, "value should be lowercased + trimmed before send")
}
func TestCreate_StorageProvider_InvalidValueReturnsInputError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{}
opts := &CreateOptions{Name: "n", StorageProvider: "azure"}
err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
// A bad enum *value* (cobra accepted the string; the app rejected it) is an
// app-level input error → exit 5, consistent with every other enum flag
// (model --type, agent --agent-mode, message search --mode).
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
assert.Equal(t, 5, cmdutil.ExitCode(err), "invalid --storage-provider must exit 5 (app-level input validation)")
assert.Contains(t, err.Error(), "--storage-provider")
assert.Nil(t, svc.got, "SDK must not be called when input validation fails")
}
func TestCreate_StorageProvider_OmittedWhenEmpty(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_n", Name: "n"}}
opts := &CreateOptions{Name: "n"} // no --storage-provider
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Nil(t, svc.got.StorageProviderConfig, "empty flag must omit StorageProviderConfig (let server pick default)")
}
+111
View File
@@ -0,0 +1,111 @@
package kb
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// kbDeleteFields enumerates the fields surfaced for `--format json` discovery
// on `kb delete`. The result payload is a small {id, deleted} object.
var kbDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {
Yes bool // sourced from the global -y/--yes persistent flag (see cli/cmd/root.go addGlobalFlags)
DryRun bool
}
// DeleteService is the narrow SDK surface this command depends on.
// *sdk.Client satisfies it.
type DeleteService interface {
DeleteKnowledgeBase(ctx context.Context, id string) error
}
// deleteResult is the typed payload emitted under data on success.
type deleteResult struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
}
// NewCmdDelete builds `weknora kb delete`. The global -y/--yes persistent
// flag is the single skip-prompt switch for the destructive-write
// confirmation pattern.
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
opts := &DeleteOptions{}
cmd := &cobra.Command{
Use: "delete <kb-id>",
Short: "Delete a knowledge base",
Long: `Permanently deletes a knowledge base and all its contents.
Prompts for confirmation by default when stdout is a TTY and JSON output is not set.
Pass -y/--yes (global flag) to skip the prompt (required in agent / CI / piped contexts).
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead — the exit-10 protocol exists
exactly to guard against unintended deletes.`,
Example: ` weknora kb delete kb_abc # interactive confirm
weknora kb delete kb_abc -y # no prompt
weknora kb delete kb_abc -y --format json # bare {id, deleted:true} JSON`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Yes, _ = c.Flags().GetBool("yes")
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: "kb.delete",
Args: map[string]any{
"kb_ids": args,
},
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runDelete(c.Context(), opts, fopts, cli, f.Prompter(), args[0])
},
}
cmdutil.AddFormatFlag(cmd, kbDeleteFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetRisk(cmd, "kb.delete")
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "permanently delete a knowledge base and all its contents",
RequiredFlags: []string{"<kb-id> (positional)"},
Output: "envelope.data is {id, deleted:true}",
Examples: []string{
"weknora kb delete kb_abc -y",
"weknora kb delete kb_abc -y --format json",
},
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
"kb delete is irreversible; whole KB + docs + chunks gone.",
},
})
return cmd
}
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter, id string) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "knowledge base", id, "kb.delete", []string{"weknora", "kb", "delete", id, "-y"}); err != nil {
return err
}
if err := svc.DeleteKnowledgeBase(ctx, id); err != nil {
return cmdutil.WrapHTTP(err, "delete knowledge base %s", id)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted knowledge base %s\n", id)
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package kb
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// TestKBDelete_DryRun_NoExit10: --dry-run must bypass ConfirmDestructive.
// Non-TTY without -y normally yields exit 10 (CodeInputConfirmationRequired);
// with --dry-run it must early-exit successfully (exit 0) because no real
// delete happens — preview implies no execution, so the confirmation gate
// is irrelevant.
func TestKBDelete_DryRun_NoExit10(t *testing.T) {
out, _ := iostreams.SetForTest(t) // non-TTY
f := kbDryRunFactory(t)
root := withRootHarness(NewCmdDelete(f), "kb_to_preview", "--dry-run", "--format", "json")
err := root.Execute()
require.NoError(t, err, "dry-run must succeed (exit 0); ConfirmDestructive must be skipped")
assert.Equal(t, 0, cmdutil.ExitCode(err), "exit code must be 0, not 10 (confirmation_required)")
var env struct {
OK bool `json:"ok"`
Meta struct {
DryRun bool `json:"dry_run"`
Plan map[string]any `json:"plan"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
assert.True(t, env.OK)
assert.True(t, env.Meta.DryRun)
assert.Equal(t, "kb.delete", env.Meta.Plan["action"])
}
// TestKBDelete_DryRunWithYes_EquivalentToDryRunAlone: -y is a no-op when
// --dry-run is set (no real delete happens, so no confirmation gate to skip).
// The envelope shape must match the dry-run-alone case exactly so agents can
// rely on the plan output regardless of whether the human pre-approved.
func TestKBDelete_DryRunWithYes_EquivalentToDryRunAlone(t *testing.T) {
// First invocation: --dry-run alone.
outA, _ := iostreams.SetForTest(t)
rootA := withRootHarness(NewCmdDelete(kbDryRunFactory(t)),
"kb_to_preview", "--dry-run", "--format", "json")
require.NoError(t, rootA.Execute())
gotA := outA.String()
// Second invocation: --dry-run + -y.
outB, _ := iostreams.SetForTest(t)
rootB := withRootHarness(NewCmdDelete(kbDryRunFactory(t)),
"kb_to_preview", "--dry-run", "-y", "--format", "json")
require.NoError(t, rootB.Execute(), "--dry-run + -y must succeed identically to --dry-run alone")
gotB := outB.String()
assert.JSONEq(t, gotA, gotB,
"--dry-run output must be identical with or without -y; -y is a no-op under dry-run")
}
+189
View File
@@ -0,0 +1,189 @@
package kb
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/testutil"
)
// fakeDeleteSvc records what id was deleted.
type fakeDeleteSvc struct {
err error
gotID string
called bool
}
func (f *fakeDeleteSvc) DeleteKnowledgeBase(_ context.Context, id string) error {
f.called = true
f.gotID = id
return f.err
}
func TestDelete_Success_WithForce(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{}
opts := &DeleteOptions{Yes: true}
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_force"))
assert.True(t, svc.called)
assert.Equal(t, "kb_force", svc.gotID)
assert.False(t, p.Asked, "--force must skip the confirm prompt")
assert.Contains(t, out.String(), "✓ Deleted")
assert.Contains(t, out.String(), "kb_force")
}
func TestDelete_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
p := &testutil.ConfirmPrompter{}
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_missing")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
// SetForTest uses bytes.Buffer for Out - IsStdoutTTY() = false. Without
// -y/--yes, exit-10 protocol fires (see cli/README.md): the CLI must NOT
// silently proceed in scripted contexts.
iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{}
err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_nontty")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.False(t, svc.called, "non-TTY without -y must not call DeleteKnowledgeBase")
assert.False(t, p.Asked, "non-TTY ⇒ Confirm is never invoked")
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol")
}
func TestDelete_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{}
opts := &DeleteOptions{Yes: true}
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_json"))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Equal(t, "kb_json", env.Data["id"], "envelope.data.id must be kb_json")
assert.Equal(t, true, env.Data["deleted"], "envelope.data.deleted must be true")
}
// The remaining tests cover the interactive confirm path which only fires
// under IsStdoutTTY() && !JSONOut - exercised via SetForTestWithTTY.
func TestDelete_ConfirmYes(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: true}
require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_yes"))
assert.True(t, p.Asked, "confirm prompt should fire on TTY without --force")
assert.True(t, svc.called, "answer=yes ⇒ delete proceeds")
assert.Equal(t, "kb_yes", svc.gotID)
}
func TestDelete_ConfirmNo(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: false}
err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_no")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.True(t, p.Asked)
assert.False(t, svc.called, "answer=no ⇒ delete must NOT run")
assert.Contains(t, errBuf.String(), "Aborted")
}
func TestDelete_ConfirmPrompterError(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{Err: prompt.ErrAgentNoPrompt}
err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_err")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code,
"prompter error should surface as missing-flag (pass --force)")
assert.False(t, svc.called)
}
func TestDelete_JSONOut_NoYes_RequiresConfirmation(t *testing.T) {
// Even on a TTY, --format json indicates a scripted caller; cannot prompt.
// Exit-10 protocol must fire when -y is absent.
iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{}
opts := &DeleteOptions{}
err := runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_jtty")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.False(t, p.Asked, "--format json must skip the prompt even on TTY")
assert.False(t, svc.called, "--format json without -y must not call DeleteKnowledgeBase")
assert.Equal(t, 10, cmdutil.ExitCode(err))
}
func TestDelete_JSONOut_WithYes_Proceeds(t *testing.T) {
// --format json + -y is the agent happy-path: scripted caller with explicit
// approval. Must call SDK and emit the bare result object.
out, _ := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{}
opts := &DeleteOptions{Yes: true}
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_jtty"))
assert.False(t, p.Asked, "-y must skip the prompt")
assert.True(t, svc.called)
assert.Contains(t, out.String(), `"deleted":true`)
}
func TestKbDelete_NoYes_JSONMode_AttachesRiskAndRetry(t *testing.T) {
// Non-TTY + JSON mode without -y must return CodeInputConfirmationRequired
// with risk.action == "kb.delete" and retry_argv == [weknora kb delete kb_x -y].
// Regression test for H1: ConfirmDestructive must attach risk + retry_argv.
iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{}
opts := &DeleteOptions{Yes: false}
err := runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_x")
require.Error(t, err, "expected confirmation_required error")
ce := cmdutil.AsError(err)
require.NotNil(t, ce, "expected *cmdutil.Error; got %T %v", err, err)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
assert.False(t, svc.called, "SDK must not be called without confirmation")
assert.False(t, p.Asked, "--format json must not prompt even on test setup")
require.NotNil(t, ce.Risk, "expected risk metadata on confirmation_required error")
assert.Equal(t, "kb.delete", ce.Risk.Action, "expected risk.action == kb.delete")
assert.Equal(t, "destructive", ce.Risk.Level, "expected risk.level == destructive")
assert.Equal(t, []string{"weknora", "kb", "delete", "kb_x", "-y"}, ce.RetryArgv, "expected retry_argv")
}
+172
View File
@@ -0,0 +1,172 @@
package kb
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// kbEditFields enumerates the fields surfaced for `--format json` discovery on
// `kb update`. The result is the updated KnowledgeBase; mirrors the kb
// top-level json tags.
var kbEditFields = []string{
"id", "name", "type", "description",
"is_temporary", "is_pinned",
"embedding_model_id", "summary_model_id",
"knowledge_count", "chunk_count",
"is_processing", "processing_count",
"created_at", "updated_at",
}
type EditOptions struct {
// Name/Description are *string so we can distinguish "unset" from "set to
// empty". An unset field is omitted from the SDK request - only fields the
// user passed are sent. Server PUT semantics are "replace everything in the
// request"; if we always sent both, an `--name` invocation would silently
// clear the description.
Name *string
Description *string
Yes bool // sourced from global -y/--yes persistent flag
DryRun bool
}
// EditService is the narrow SDK surface this command depends on. GetKnowledgeBase
// is needed for the fetch-then-update flow: the server's UpdateKnowledgeBase
// endpoint requires Name on the PUT body (UpdateKnowledgeBaseRequest.Name is
// `string`, not `*string`, and the server validates `required`), so passing
// only --description without fetching the current Name would 400.
type EditService interface {
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
UpdateKnowledgeBase(ctx context.Context, id string, req *sdk.UpdateKnowledgeBaseRequest) (*sdk.KnowledgeBase, error)
}
// NewCmdEdit builds `weknora kb update <id>`. At least one of --name /
// --description must be provided.
func NewCmdEdit(f *cmdutil.Factory) *cobra.Command {
opts := &EditOptions{}
var name, desc string
cmd := &cobra.Command{
Use: "update <kb-id>",
Short: "Update a knowledge base's name or description",
Long: `Update a knowledge base's name and/or description. At least one of
--name / --description must be supplied; fields you omit are preserved
server-side.
AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10
with input.confirmation_required. Never auto-pass -y; surface the prompt
to the user first.`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Yes, _ = c.Flags().GetBool("yes")
if c.Flag("name").Changed {
opts.Name = &name
}
if c.Flag("description").Changed {
opts.Description = &desc
}
id := args[0]
// Validate pure-local "at least one mutation flag" before the
// dry-run gate so --dry-run rejects identically to the live path.
// Same typed Error as runEdit so live behavior is unchanged, just
// earlier.
if opts.Name == nil && opts.Description == nil {
return &cmdutil.Error{
Code: cmdutil.CodeInputMissingFlag,
Message: "kb update requires at least one of --name or --description",
Hint: "pass --name <name> and/or --description <desc>",
}
}
planArgs := map[string]any{"kb": id}
if opts.Name != nil {
planArgs["name"] = *opts.Name
}
if opts.Description != nil {
planArgs["description"] = *opts.Description
}
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: "kb.update",
Args: planArgs,
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
// Build a retry command from the flags the user actually passed so
// agents can re-invoke with -y after explicit human approval.
retryCmd := cmdutil.BuildRetryArgv(c, []string{"weknora", "kb", "update", id}, "name", "description", "format")
if err := cmdutil.ConfirmWrite(f.Prompter(), opts.Yes, fopts.WantsJSON(), "update", "knowledge base", id, "kb.update", retryCmd); err != nil {
return err
}
return runEdit(c.Context(), opts, fopts, cli, id)
},
}
cmd.Flags().StringVar(&name, "name", "", "New name (omit to leave unchanged)")
cmd.Flags().StringVar(&desc, "description", "", "New description (omit to leave unchanged)")
cmdutil.AddFormatFlag(cmd, kbEditFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetWriteRisk(cmd, "kb.update")
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "update a knowledge base's name or description",
RequiredFlags: []string{"<kb-id> (positional)", "--name or --description (at least one)"},
Output: "envelope.data is the updated KnowledgeBase object (id, name, description)",
Examples: []string{
"weknora kb update kb_abc --name \"New Name\" -y",
"weknora kb update kb_abc --description \"Updated desc\" --format json -y",
},
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
"kb update overwrites fields; SDK uses fetch-then-update to avoid clobbering, but malformed input can still corrupt config.",
},
})
return cmd
}
func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOptions, svc EditService, id string) error {
if opts.Name == nil && opts.Description == nil {
return &cmdutil.Error{
Code: cmdutil.CodeInputMissingFlag,
Message: "kb update requires at least one of --name or --description",
Hint: "pass --name <name> and/or --description <desc>",
}
}
// Fetch current state so we can fill in fields the user didn't touch.
// TOCTOU note: another writer could change Name/Description between
// our Get and Put; matches the same race window kb pin / unpin have.
current, err := svc.GetKnowledgeBase(ctx, id)
if err != nil {
return cmdutil.WrapHTTP(err, "fetch knowledge base %s", id)
}
req := &sdk.UpdateKnowledgeBaseRequest{
Name: current.Name,
Description: current.Description,
}
if opts.Name != nil {
req.Name = *opts.Name
}
if opts.Description != nil {
req.Description = *opts.Description
}
updated, err := svc.UpdateKnowledgeBase(ctx, id, req)
if err != nil {
return cmdutil.WrapHTTP(err, "update knowledge base %s", id)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, updated, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Updated knowledge base %s\n", id)
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package kb
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// TestKBEdit_DryRun_RejectsNoMutationFlag: --dry-run must reject the same
// "no mutation flag supplied" invocation the live path rejects. Before the
// fix the validation lived in runEdit() (the live-only callee) and was
// reached only after HandleDryRun early-exited, so --dry-run silently
// emitted a no-op plan envelope. Validation must run before previewing.
func TestKBEdit_DryRun_RejectsNoMutationFlag(t *testing.T) {
iostreams.SetForTest(t)
f := kbDryRunFactory(t)
root := withRootHarness(NewCmdEdit(f), "kb_abc", "--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject identically to live path")
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code, "must use the same code runEdit emits")
}
+165
View File
@@ -0,0 +1,165 @@
package kb
import (
"context"
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeEditSvc captures the (id, request) pair handed to UpdateKnowledgeBase
// and scripts the GetKnowledgeBase fetch used by the fetch-then-update path.
type fakeEditSvc struct {
current *sdk.KnowledgeBase // returned by GetKnowledgeBase
currentErr error
gotID string
gotReq *sdk.UpdateKnowledgeBaseRequest
resp *sdk.KnowledgeBase
err error
}
func (f *fakeEditSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
if f.currentErr != nil {
return nil, f.currentErr
}
if f.current != nil {
return f.current, nil
}
return &sdk.KnowledgeBase{ID: id}, nil
}
func (f *fakeEditSvc) UpdateKnowledgeBase(_ context.Context, id string, req *sdk.UpdateKnowledgeBaseRequest) (*sdk.KnowledgeBase, error) {
f.gotID = id
f.gotReq = req
return f.resp, f.err
}
func TestEdit_RequiresAtLeastOneFlag(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{}
err := runEdit(context.Background(), &EditOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
assert.Contains(t, typed.Hint, "--name")
assert.Contains(t, typed.Hint, "--description")
}
// When only --name is passed, the request must carry the user's new name
// AND the current Description (preserved via the fetch). Sending Description=""
// would clobber the server-side value because UpdateKnowledgeBaseRequest
// fields are `string`, not `*string`.
func TestEdit_OnlyName_PreservesCurrentDescription(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeEditSvc{
current: &sdk.KnowledgeBase{ID: "kb_abc", Name: "old", Description: "keep me"},
resp: &sdk.KnowledgeBase{ID: "kb_abc", Name: "new", Description: "keep me"},
}
opts := &EditOptions{}
opts.Name = stringPtr("new")
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc"))
assert.Equal(t, "kb_abc", svc.gotID)
require.NotNil(t, svc.gotReq)
assert.Equal(t, "new", svc.gotReq.Name)
assert.Equal(t, "keep me", svc.gotReq.Description, "Description must be preserved from fetch when not in --description")
assert.Contains(t, out.String(), "kb_abc")
}
// Symmetric: only --description must preserve current Name. The server's
// `Name required` validation made this case fail without the fetch.
func TestEdit_OnlyDescription_PreservesCurrentName(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{
current: &sdk.KnowledgeBase{ID: "kb_abc", Name: "keep me", Description: "old"},
resp: &sdk.KnowledgeBase{ID: "kb_abc"},
}
opts := &EditOptions{}
opts.Description = stringPtr("new desc")
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc"))
require.NotNil(t, svc.gotReq)
assert.Equal(t, "new desc", svc.gotReq.Description)
assert.Equal(t, "keep me", svc.gotReq.Name, "Name must be preserved from fetch when not in --name")
}
func TestEdit_BothFlags(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{resp: &sdk.KnowledgeBase{ID: "kb_abc"}}
opts := &EditOptions{}
opts.Name = stringPtr("renamed")
opts.Description = stringPtr("new desc")
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc"))
assert.Equal(t, "renamed", svc.gotReq.Name)
assert.Equal(t, "new desc", svc.gotReq.Description)
}
func TestEdit_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
// 404 must come from the GetKnowledgeBase pre-fetch in the fetch-then-
// update flow - that's the first server roundtrip when the id is bad.
svc := &fakeEditSvc{currentErr: errors.New("HTTP error 404: not found")}
opts := &EditOptions{}
opts.Name = stringPtr("x")
err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_missing")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func stringPtr(s string) *string { return &s }
// withRootHarnessKB wraps `weknora kb update ...` under a synthetic root cmd
// that registers the global persistent flags (mirrors addGlobalFlags in
// cmd/root.go). Required because NewCmdEdit reads --yes and --format from the
// persistent flag set.
func withRootHarnessKB(edit *cobra.Command, args ...string) *cobra.Command {
root := &cobra.Command{Use: "weknora"}
pf := root.PersistentFlags()
pf.BoolP("yes", "y", false, "")
pf.String("format", "", "Output format: text | json | ndjson")
pf.StringP("jq", "q", "", "")
kb := &cobra.Command{Use: "kb"}
kb.AddCommand(edit)
root.AddCommand(kb)
root.SetArgs(append([]string{"kb", "update"}, args...))
root.SetContext(context.Background())
root.SilenceErrors = true
root.SilenceUsage = true
return root
}
// TestKBEdit_RequiresConfirmation asserts that without -y (non-TTY / JSON
// mode), kb update returns input.confirmation_required (exit 10).
func TestKBEdit_RequiresConfirmation(t *testing.T) {
iostreams.SetForTest(t) // non-TTY
svc := &fakeEditSvc{
current: &sdk.KnowledgeBase{ID: "kb_abc", Name: "old"},
resp: &sdk.KnowledgeBase{ID: "kb_abc", Name: "new"},
}
f := &cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, nil },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
}
_ = svc // gate fires before SDK call
root := withRootHarnessKB(NewCmdEdit(f), "kb_abc", "--name", "new", "--format", "json")
err := root.Execute()
require.Error(t, err)
var ce *cmdutil.Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
assert.Equal(t, 10, cmdutil.ExitCode(err))
// retry argv must include -y
assert.Contains(t, ce.RetryArgv, "-y")
assert.Contains(t, ce.RetryArgv, "kb_abc")
}
+30
View File
@@ -0,0 +1,30 @@
// Package kb holds the `weknora kb` command tree: list / view / create /
// update / delete / pin / unpin. Verb set follows common CRUD vocabulary
// (list/view/create/update/delete) plus pin/unpin. Bulk content deletion
// is exposed via `weknora doc delete --all --kb=<id>`.
package kb
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// NewCmd builds the `weknora kb` parent command.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "kb",
Short: "Manage knowledge bases",
}
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdView(f))
cmd.AddCommand(NewCmdCreate(f))
cmd.AddCommand(NewCmdEdit(f))
cmd.AddCommand(NewCmdDelete(f))
cmd.AddCommand(NewCmdPin(f))
cmd.AddCommand(NewCmdUnpin(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdCheck(f))
cmd.AddCommand(NewCmdConfig(f)) // `config` also hosts the `config set` write subcommand
return cmd
}
+159
View File
@@ -0,0 +1,159 @@
package kb
import (
"context"
"fmt"
"sort"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// kbListFields enumerates the fields surfaced for `--format json` discovery on
// `kb list`. Nested config structs (chunking / image / FAQ / VLM / storage
// / extract) are intentionally omitted - users wanting those can use `--jq`
// against the full object.
var kbListFields = []string{
"id", "name", "type", "description",
"is_temporary", "is_pinned",
"embedding_model_id", "summary_model_id",
"knowledge_count", "chunk_count",
"is_processing", "processing_count",
"created_at", "updated_at",
}
// ListOptions captures `kb list` filter flag state.
type ListOptions struct {
Pinned bool // --pinned: client-side filter to KBs with IsPinned == true
// Limit caps the returned slice client-side. 0 = no cap, 1..10000 = explicit.
// The KB list SDK is unpaginated; --all-pages is intentionally not exposed
// because it would be a no-op.
Limit int
}
// ListService is the narrow SDK surface this command depends on.
type ListService interface {
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
}
// NewCmdList builds `weknora kb list`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
cmd := &cobra.Command{
Use: "list",
Short: "List knowledge bases visible to the active profile",
Long: `List knowledge bases visible to the active profile, sorted by most recently updated. Pass --pinned to restrict to pinned KBs.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Validate static input before building the client so a bad --limit
// returns input.invalid_argument (exit 5) rather than an auth error
// (exit 3) when no profile is configured.
if err := validateListOpts(opts); err != nil {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runList(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().BoolVar(&opts.Pinned, "pinned", false, "Only show pinned knowledge bases")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)")
cmdutil.AddFormatFlag(cmd, kbListFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "List knowledge bases in the current tenant. --format json emits the standard envelope {ok, data:[...], meta:{count}, profile}.",
Examples: []string{"weknora kb list --format json"},
Output: "envelope.data is an array of KnowledgeBase objects with id, name, is_pinned, type, embedding_model_id; meta.total_count is the full tenant set and meta.has_more=true means --limit truncated it (raise --limit to get the rest)",
})
return cmd
}
// validateListOpts checks --limit. Called from RunE before the client is built
// (so a bad value surfaces as exit 5, not an auth error) and at runList's top
// for direct callers; idempotent.
func validateListOpts(opts *ListOptions) error {
if opts.Limit < 1 || opts.Limit > 10000 {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit),
}
}
return nil
}
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
if err := validateListOpts(opts); err != nil {
return err
}
items, err := svc.ListKnowledgeBases(ctx)
if err != nil {
return cmdutil.WrapHTTP(err, "list knowledge bases")
}
if items == nil {
items = []sdk.KnowledgeBase{} // ensure JSON [] not null
}
if opts.Pinned {
filtered := items[:0]
for _, kb := range items {
if kb.IsPinned {
filtered = append(filtered, kb)
}
}
items = filtered
}
// Default sort by updated_at desc. Server return order is not
// guaranteed, so client-side sort makes output deterministic regardless
// of backend storage choices.
sort.Slice(items, func(i, j int) bool {
return items[i].UpdatedAt.After(items[j].UpdatedAt)
})
// The KB list SDK is unpaginated — it returns every KB in one call —
// so the CLI holds the true total and can tell the caller whether the
// client-side --limit dropped any. total_count is the full count;
// has_more flags that --limit truncated it (raise --limit to get the
// rest — there is no server cursor to continue with).
total := len(items)
truncated := false
if opts.Limit > 0 && len(items) > opts.Limit {
items = items[:opts.Limit]
truncated = true
}
if fopts.WantsJSON() {
meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(total)}
return fopts.Emit(iostreams.IO.Out, items, meta)
}
if len(items) == 0 {
if opts.Pinned {
fmt.Fprintln(iostreams.IO.Out, "(no pinned knowledge bases)")
return nil
}
fmt.Fprintln(iostreams.IO.Out, "(no knowledge bases)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tDOCS\tUPDATED")
now := time.Now()
for _, kb := range items {
name := text.Truncate(40, kb.Name)
docs := text.Pluralize(int(kb.KnowledgeCount), "doc")
updated := text.FuzzyAgo(now, kb.UpdatedAt)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", kb.ID, name, docs, updated)
}
return tw.Flush()
}
+242
View File
@@ -0,0 +1,242 @@
package kb
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeListSvc struct {
items []sdk.KnowledgeBase
err error
}
func (f *fakeListSvc) ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error) {
return f.items, f.err
}
func TestList_Empty_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
t.Fatalf("runList: %v", err)
}
if !strings.Contains(out.String(), "(no knowledge bases)") {
t.Errorf("empty output expected '(no knowledge bases)', got %q", out.String())
}
}
func TestList_Empty_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
t.Fatalf("runList: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data []sdk.KnowledgeBase `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("parse: %v\n%s", err, out.String())
}
if !env.OK {
t.Error("envelope.ok must be true")
}
if len(env.Data) != 0 {
t.Errorf("expected empty data, got %d items", len(env.Data))
}
}
func TestList_NonEmpty_Text_RenderColumns(t *testing.T) {
out, _ := iostreams.SetForTest(t)
now := time.Now()
items := []sdk.KnowledgeBase{
{ID: "kb1", Name: "Marketing", KnowledgeCount: 5, UpdatedAt: now.Add(-3 * time.Hour)},
{ID: "kb2", Name: "Engineering", KnowledgeCount: 1, UpdatedAt: now.Add(-2 * 24 * time.Hour)},
}
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
got := out.String()
for _, want := range []string{"ID", "NAME", "DOCS", "UPDATED", "kb1", "Marketing", "5 docs", "kb2", "Engineering", "1 doc"} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q in:\n%s", want, got)
}
}
}
func TestList_JSON_JQProjection(t *testing.T) {
out, _ := iostreams.SetForTest(t)
now := time.Now()
items := []sdk.KnowledgeBase{
{ID: "kb1", Name: "Marketing", Description: "MKT desc", UpdatedAt: now},
}
// --jq projects from the envelope; .data[] | ... extracts from the array inside envelope.
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data[] | {id, name}"}
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
var item map[string]any
if err := json.Unmarshal(out.Bytes(), &item); err != nil {
t.Fatalf("parse: %v\n%s", err, out.String())
}
if item["id"] != "kb1" || item["name"] != "Marketing" {
t.Errorf("kept fields wrong: %+v", item)
}
if _, has := item["description"]; has {
t.Errorf("description should be dropped, got: %+v", item)
}
}
func TestList_JSON_JQ(t *testing.T) {
out, _ := iostreams.SetForTest(t)
now := time.Now()
items := []sdk.KnowledgeBase{
{ID: "kb1", Name: "Marketing", UpdatedAt: now},
{ID: "kb2", Name: "Engineering", UpdatedAt: now.Add(-time.Hour)},
}
// .data | length counts the items inside the envelope's data array.
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data | length"}
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "2" {
t.Errorf("expected '2', got %q", got)
}
}
func TestList_PinnedFilter(t *testing.T) {
out, _ := iostreams.SetForTest(t)
now := time.Now()
items := []sdk.KnowledgeBase{
{ID: "kb1", Name: "Marketing", IsPinned: true, UpdatedAt: now},
{ID: "kb2", Name: "Engineering", IsPinned: false, UpdatedAt: now.Add(-time.Hour)},
{ID: "kb3", Name: "Finance", IsPinned: true, UpdatedAt: now.Add(-2 * time.Hour)},
}
if err := runList(context.Background(), &ListOptions{Pinned: true, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
got := out.String()
if !strings.Contains(got, "kb1") || !strings.Contains(got, "kb3") {
t.Errorf("expected pinned KBs kb1 and kb3 in output, got:\n%s", got)
}
if strings.Contains(got, "kb2") {
t.Errorf("unpinned kb2 should be filtered out, got:\n%s", got)
}
}
func TestList_PinnedFilter_NoPinned_TextMessage(t *testing.T) {
out, _ := iostreams.SetForTest(t)
items := []sdk.KnowledgeBase{
{ID: "kb1", Name: "Marketing", IsPinned: false, UpdatedAt: time.Now()},
}
if err := runList(context.Background(), &ListOptions{Pinned: true, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
if !strings.Contains(out.String(), "(no pinned knowledge bases)") {
t.Errorf("expected pinned-specific empty message, got: %q", out.String())
}
}
// makeKBs returns N KBs with distinct IDs and descending UpdatedAt.
func makeKBs(n int) []sdk.KnowledgeBase {
base := time.Now()
out := make([]sdk.KnowledgeBase, n)
for i := 0; i < n; i++ {
out[i] = sdk.KnowledgeBase{
ID: fmt.Sprintf("kb_%02d", i),
Name: fmt.Sprintf("kb-%02d", i),
UpdatedAt: base.Add(-time.Duration(i) * time.Hour),
}
}
return out
}
func TestList_Limit_CapsResults(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: makeKBs(20)}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := runList(context.Background(), &ListOptions{Limit: 5}, fopts, svc); err != nil {
t.Fatalf("runList: %v", err)
}
got := strings.Count(out.String(), `"id":"kb_`)
if got != 5 {
t.Errorf("--limit 5 should slice 20 items to 5; got %d in:\n%s", got, out.String())
}
}
// TestList_Truncation_SignalsHasMoreAndTotal pins that a client-side --limit
// truncation tells the agent it did NOT get everything: has_more=true and
// total_count=full set. Regression: kb list silently dropped items past
// --limit with no completeness signal, so an agent listing to find a KB by
// name could miss KBs beyond position 30 and never know.
func TestList_Truncation_SignalsHasMoreAndTotal(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: makeKBs(20)}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := runList(context.Background(), &ListOptions{Limit: 5}, fopts, svc); err != nil {
t.Fatalf("runList: %v", err)
}
if !strings.Contains(out.String(), `"has_more":true`) {
t.Errorf("truncated list must set has_more:true; got:\n%s", out.String())
}
if !strings.Contains(out.String(), `"total_count":20`) {
t.Errorf("truncated list must report total_count:20; got:\n%s", out.String())
}
}
// TestList_NoTruncation_OmitsHasMore pins that when --limit does NOT truncate,
// has_more is absent (omitempty) so the agent reads "complete".
func TestList_NoTruncation_OmitsHasMore(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: makeKBs(3)}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, svc); err != nil {
t.Fatalf("runList: %v", err)
}
if strings.Contains(out.String(), `"has_more"`) {
t.Errorf("non-truncated list must omit has_more; got:\n%s", out.String())
}
if !strings.Contains(out.String(), `"total_count":3`) {
t.Errorf("list must report total_count:3; got:\n%s", out.String())
}
}
func TestList_Limit_Zero_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{items: makeKBs(7)}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
err := runList(context.Background(), &ListOptions{Limit: 0}, fopts, svc)
if err == nil {
t.Fatal("expected error for --limit 0")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err)
}
if typed.Code != cmdutil.CodeInputInvalidArgument {
t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code)
}
}
func TestList_Limit_Negative_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
err := runList(context.Background(), &ListOptions{Limit: -1}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: makeKBs(3)})
if err == nil {
t.Fatal("expected error for negative --limit")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err)
}
if typed.Code != cmdutil.CodeInputInvalidArgument {
t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code)
}
}
+137
View File
@@ -0,0 +1,137 @@
package kb
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// kbPinFields enumerates the fields surfaced for `--format json` discovery on
// `kb pin` / `kb unpin`. The toggle result is the KnowledgeBase; the user-
// relevant fields here are the id and the new pin state.
var kbPinFields = []string{"id", "is_pinned"}
type PinOptions struct {
DryRun bool
}
// PinService is the narrow SDK surface this command depends on. The CLI
// reads current state before toggling so `pin`/`unpin` are idempotent — the
// server endpoint is only a toggle. Pin state is read from the LIST endpoint:
// the single-KB GET does not carry per-user pin state (pin is a per-user
// list-ordering preference, surfaced where the user sees it — in `kb list`),
// so the list is the canonical source.
type PinService interface {
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
TogglePinKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
}
// NewCmdPin builds `weknora kb pin <id>`.
func NewCmdPin(f *cmdutil.Factory) *cobra.Command {
return newPinCmd(f, "pin", true, "Pin a knowledge base to the top of the list")
}
// NewCmdUnpin builds `weknora kb unpin <id>`.
func NewCmdUnpin(f *cmdutil.Factory) *cobra.Command {
return newPinCmd(f, "unpin", false, "Unpin a knowledge base")
}
func newPinCmd(f *cmdutil.Factory, use string, want bool, short string) *cobra.Command {
opts := &PinOptions{}
cmd := &cobra.Command{
Use: use + " <kb-id>",
Short: short,
Long: short + ". Idempotent: reads the current pin state and toggles only if different, so re-running on a KB already in the target state is a no-op.",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
action := "kb.pin"
if !want {
action = "kb.unpin"
}
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: action,
Args: map[string]any{"kb": args[0]},
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runPin(c.Context(), opts, fopts, cli, args[0], want)
},
}
cmdutil.AddFormatFlag(cmd, kbPinFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: use + " a knowledge base (idempotent: a no-op if it is already in the target state)",
RequiredFlags: []string{"<kb-id> (positional)"},
Examples: []string{"weknora kb " + use + " kb_abc"},
Output: "envelope.data is the KnowledgeBase with is_pinned reflecting the new state",
})
return cmd
}
func runPin(ctx context.Context, opts *PinOptions, fopts *cmdutil.FormatOptions, svc PinService, id string, want bool) error {
verb := "pin"
if !want {
verb = "unpin"
}
// Read the current pin state from the list (the canonical per-user pin
// source); the single-KB GET doesn't carry it. This makes pin/unpin
// idempotent: we toggle only when the state actually needs to change.
kbs, err := svc.ListKnowledgeBases(ctx)
if err != nil {
return cmdutil.WrapHTTP(err, "list knowledge bases")
}
var current *sdk.KnowledgeBase
for i := range kbs {
if kbs[i].ID == id {
current = &kbs[i]
break
}
}
if current == nil {
return cmdutil.NewError(cmdutil.CodeResourceNotFound,
fmt.Sprintf("knowledge base not found: %s", id))
}
if current.IsPinned == want {
state := "pinned"
if !want {
state = "unpinned"
}
// No-op path: the resource is already in the requested state. We
// emit the current resource so callers see the canonical shape on
// both fresh-toggle and no-op paths. Text path prints a confirming
// line; agents observe via the unchanged is_pinned field.
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, current, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ %s is already %s\n", id, state)
return nil
}
updated, err := svc.TogglePinKnowledgeBase(ctx, id)
if err != nil {
return cmdutil.WrapHTTP(err, "%s knowledge base %s", verb, id)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, updated, nil)
}
state := "pinned"
if !updated.IsPinned {
state = "unpinned"
}
fmt.Fprintf(iostreams.IO.Out, "✓ %s %s\n", id, state)
return nil
}
+142
View File
@@ -0,0 +1,142 @@
package kb
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// fakePinSvc satisfies PinService: ListKnowledgeBases + TogglePinKnowledgeBase.
// The fake's `current` is returned as the single KB in the list (the list is
// the canonical pin-state source).
type fakePinSvc struct {
current sdk.KnowledgeBase
getErr error
toggleErr error
toggleCalled bool
}
func (f *fakePinSvc) ListKnowledgeBases(_ context.Context) ([]sdk.KnowledgeBase, error) {
if f.getErr != nil {
return nil, f.getErr
}
c := f.current
if c.ID == "" {
c.ID = "kb_abc" // tests address this id
}
return []sdk.KnowledgeBase{c}, nil
}
func (f *fakePinSvc) TogglePinKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
f.toggleCalled = true
if f.toggleErr != nil {
return nil, f.toggleErr
}
c := f.current
c.ID = id
c.IsPinned = !c.IsPinned
return &c, nil
}
func TestPin_UnpinnedToPinned_CallsToggle(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}}
require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", true))
assert.True(t, svc.toggleCalled, "must call toggle when current state differs")
assert.Contains(t, out.String(), "kb_abc")
}
func TestPin_AlreadyPinned_NoOp(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: true}}
require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", true))
assert.False(t, svc.toggleCalled, "already pinned ⇒ must not call toggle")
assert.Contains(t, out.String(), "already pinned")
}
func TestUnpin_PinnedToUnpinned_CallsToggle(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: true}}
require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", false))
assert.True(t, svc.toggleCalled)
}
func TestUnpin_AlreadyUnpinned_NoOp(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}}
require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", false))
assert.False(t, svc.toggleCalled, "already unpinned ⇒ must not call toggle")
assert.Contains(t, out.String(), "already unpinned")
}
func TestPin_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakePinSvc{getErr: errors.New("HTTP error 404: not found")}
err := runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_missing", true)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
assert.False(t, svc.toggleCalled)
}
func TestPin_ToggleError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakePinSvc{
current: sdk.KnowledgeBase{IsPinned: false},
toggleErr: errors.New("HTTP error 500: internal"),
}
err := runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", true)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
func TestPin_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}}
require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc", true))
body := out.String()
assert.Contains(t, body, `"is_pinned":true`)
assert.Contains(t, body, `"id":"kb_abc"`)
}
// TestPin_DryRun_NoServerCall: --dry-run must emit a kb.pin plan (exit 0)
// without reaching the server, so pin/unpin honor the same mutation-preview
// contract as create/edit/delete. Regression for pin/unpin lacking --dry-run.
func TestPin_DryRun_NoServerCall(t *testing.T) {
for _, tc := range []struct {
name, want string
cmd func(*cmdutil.Factory) *cobra.Command
}{
{"pin", "kb.pin", NewCmdPin},
{"unpin", "kb.unpin", NewCmdUnpin},
} {
t.Run(tc.name, func(t *testing.T) {
out, _ := iostreams.SetForTest(t)
root := withRootHarness(tc.cmd(kbDryRunFactory(t)), "kb_x", "--dry-run", "--format", "json")
require.NoError(t, root.Execute(), "dry-run must succeed without a client")
var env struct {
OK bool `json:"ok"`
Meta struct {
DryRun bool `json:"dry_run"`
Plan map[string]any `json:"plan"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String())
assert.True(t, env.OK)
assert.True(t, env.Meta.DryRun)
assert.Equal(t, tc.want, env.Meta.Plan["action"])
})
}
}
+139
View File
@@ -0,0 +1,139 @@
package kb
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// StatusResult is the health-oriented response for `kb status <id>`.
// Shallow read only: 1 HTTP call, no failed-doc aggregation.
// For deep verification including failed_count, use `kb check <id>`.
type StatusResult struct {
ID string `json:"id"`
Reachable bool `json:"reachable"`
// RetrievalReady is false when the KB has no embedding model bound — it can
// never index or retrieve until `kb config set` runs. Always emitted (no
// omitempty) so a not-ready KB is visible, not silently green.
RetrievalReady bool `json:"retrieval_ready"`
KnowledgeCount int64 `json:"knowledge_count,omitempty"`
ChunkCount int64 `json:"chunk_count,omitempty"`
IsProcessing bool `json:"is_processing,omitempty"`
ProcessingCount int64 `json:"processing_count,omitempty"`
}
// StatusService is the narrow SDK surface needed for kb status.
type StatusService interface {
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
}
var kbStatusFields = []string{
"id", "reachable", "retrieval_ready", "knowledge_count", "chunk_count",
"is_processing", "processing_count",
}
// NewCmdStatus builds `weknora kb status <id>`.
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "status <kb-id>",
Short: "Show health status of a knowledge base (shallow, 1 HTTP)",
Long: `Show health-oriented fields for a KB.
1 HTTP call:
reachable / knowledge_count / chunk_count / is_processing / processing_count
For deep verification including failed_count, use 'weknora kb check <id>'
(1 + N HTTP, pages the doc list with parse_status=failed).
For full metadata (config / pinned / tenant), use 'weknora kb view <id>'.`,
Example: ` weknora kb status kb_abc
weknora kb status kb_abc --format json`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
res, err := runStatus(c.Context(), cli, args[0])
if err != nil {
return err
}
return emitStatus(res, fopts, iostreams.IO.Out)
},
}
cmdutil.AddFormatFlag(cmd, kbStatusFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "shallow health probe of a knowledge base (one HTTP call): reachability, no failed-doc aggregation",
RequiredFlags: []string{"<kb-id> (positional)"},
Examples: []string{"weknora kb status kb_abc"},
Output: "envelope.data is {id, reachable, retrieval_ready, ...}; retrieval_ready=false means no embedding model is bound (run `kb config set`), so the KB cannot index/retrieve; use `kb check` for deep failed-doc aggregation",
})
return cmd
}
// runStatus is the testable core: fetch KB metadata and return a StatusResult.
// Never returns an error for "kb not reachable" (Reachable=false carries
// that signal).
func runStatus(ctx context.Context, svc StatusService, id string) (*StatusResult, error) {
kb, err := svc.GetKnowledgeBase(ctx, id)
if err != nil {
return &StatusResult{ID: id, Reachable: false}, nil
}
return &StatusResult{
ID: kb.ID,
Reachable: true,
RetrievalReady: kb.EmbeddingModelID != "",
KnowledgeCount: kb.KnowledgeCount,
ChunkCount: kb.ChunkCount,
IsProcessing: kb.IsProcessing,
ProcessingCount: kb.ProcessingCount,
}, nil
}
// emitStatus renders res using --format options. Mirrors emitWaitResult
// pattern from cli/cmd/doc/wait.go for consistency.
func emitStatus(res *StatusResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
switch fopts.Mode {
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
return fopts.Emit(w, res, nil)
case cmdutil.FormatText, "":
return writeStatusText(w, res)
default:
return fmt.Errorf("unsupported --format %q for kb status", fopts.Mode)
}
}
func writeStatusText(w io.Writer, res *StatusResult) error {
fmt.Fprintf(w, "ID: %s\n", res.ID)
fmt.Fprintf(w, "Reachable: %v\n", res.Reachable)
if !res.Reachable {
return nil
}
fmt.Fprintf(w, "Retrieval: %v%s\n", res.RetrievalReady, retrievalHint(res.RetrievalReady))
fmt.Fprintf(w, "Knowledge: %d\n", res.KnowledgeCount)
fmt.Fprintf(w, "Chunks: %d\n", res.ChunkCount)
fmt.Fprintf(w, "Processing: %v (%d active)\n", res.IsProcessing, res.ProcessingCount)
return nil
}
// retrievalHint annotates a not-ready KB in text output with the fix.
func retrievalHint(ready bool) string {
if ready {
return ""
}
return " ← no embedding model bound; run `weknora kb config set <id>`"
}
// compile-time check: SDK client satisfies StatusService.
var _ StatusService = (*sdk.Client)(nil)
+110
View File
@@ -0,0 +1,110 @@
package kb
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeStatusSvc struct {
kb *sdk.KnowledgeBase
getErr error
}
func (f *fakeStatusSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
if f.getErr != nil {
return nil, f.getErr
}
return f.kb, nil
}
func TestRunStatus_ShallowFields(t *testing.T) {
svc := &fakeStatusSvc{kb: &sdk.KnowledgeBase{
ID: "kb_x",
KnowledgeCount: 42,
ChunkCount: 100,
IsProcessing: true,
ProcessingCount: 3,
EmbeddingModelID: "emb_1",
}}
res, err := runStatus(context.Background(), svc, "kb_x")
if err != nil {
t.Fatalf("runStatus: %v", err)
}
if !res.Reachable {
t.Error("Reachable=false, want true")
}
if res.KnowledgeCount != 42 || res.ChunkCount != 100 || res.ProcessingCount != 3 || !res.IsProcessing {
t.Errorf("got %+v", res)
}
if !res.RetrievalReady {
t.Error("RetrievalReady=false, want true when an embedding model is bound")
}
}
// A KB with no embedding model can never retrieve — the health probe must say
// so (retrieval_ready=false), not report a silent all-green status.
func TestRunStatus_RetrievalNotReadyWithoutEmbeddingModel(t *testing.T) {
svc := &fakeStatusSvc{kb: &sdk.KnowledgeBase{ID: "kb_x", KnowledgeCount: 1}}
res, err := runStatus(context.Background(), svc, "kb_x")
if err != nil {
t.Fatalf("runStatus: %v", err)
}
if res.RetrievalReady {
t.Error("RetrievalReady=true, want false when no embedding model is bound")
}
}
func TestRunStatus_Unreachable(t *testing.T) {
svc := &fakeStatusSvc{getErr: fmt.Errorf("404 not found")}
res, err := runStatus(context.Background(), svc, "kb_x")
if err != nil {
t.Fatalf("runStatus should not return err on unreachable; got %v", err)
}
if res.Reachable {
t.Error("Reachable=true, want false")
}
if res.ID != "kb_x" {
t.Errorf("ID=%q, want kb_x (echoed even when unreachable)", res.ID)
}
}
func TestEmitStatus_JSON(t *testing.T) {
var buf bytes.Buffer
res := &StatusResult{ID: "kb_x", Reachable: true, KnowledgeCount: 5}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := emitStatus(res, fopts, &buf); err != nil {
t.Fatalf("emitStatus: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data StatusResult `json:"data"`
}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("not JSON: %v\n%s", err, buf.String())
}
got := env.Data
if got.ID != "kb_x" || got.KnowledgeCount != 5 {
t.Errorf("got %+v", got)
}
}
func TestEmitStatus_Text(t *testing.T) {
var buf bytes.Buffer
res := &StatusResult{ID: "kb_x", Reachable: true, KnowledgeCount: 5, ChunkCount: 20, IsProcessing: true, ProcessingCount: 1}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
if err := emitStatus(res, fopts, &buf); err != nil {
t.Fatalf("emitStatus: %v", err)
}
for _, want := range []string{"kb_x", "5", "20", "true"} {
if !strings.Contains(buf.String(), want) {
t.Errorf("output missing %q:\n%s", want, buf.String())
}
}
}
+128
View File
@@ -0,0 +1,128 @@
package kb
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// kbViewFields enumerates the fields surfaced for `--format json` discovery on
// `kb view`. Lists the KnowledgeBase top-level json tags; nested config
// structs are omitted (use --jq for those).
var kbViewFields = []string{
"id", "name", "type", "description",
"is_temporary", "is_pinned",
"embedding_model_id", "summary_model_id",
"knowledge_count", "chunk_count",
"is_processing", "processing_count",
"created_at", "updated_at",
}
type ViewOptions struct{}
// ViewService is the narrow SDK surface this command depends on.
// ListKnowledgeBases is used only to enrich is_pinned (the single-KB GET
// doesn't carry per-user pin state — pin is a per-user list concept).
type ViewService interface {
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
}
// NewCmdView builds `weknora kb view <id>`.
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
opts := &ViewOptions{}
cmd := &cobra.Command{
Use: "view <kb-id>",
Short: "Show a knowledge base by ID",
Long: `Fetch a knowledge base's full configuration: chunking settings, embedding / summary model IDs, knowledge_count, chunk_count.`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runView(c.Context(), opts, fopts, cli, args[0])
},
}
cmdutil.AddFormatFlag(cmd, kbViewFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "fetch one knowledge base's full configuration by id",
RequiredFlags: []string{"<kb-id> (positional)"},
Examples: []string{
"weknora kb view kb_abc",
"weknora kb view kb_abc --jq .data.embedding_model_id",
},
Output: "envelope.data is the KnowledgeBase object (id, name, type, embedding/summary model ids, chunk_count, ...)",
})
return cmd
}
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService, id string) error {
kb, err := svc.GetKnowledgeBase(ctx, id)
if err != nil {
return cmdutil.WrapHTTP(err, "get knowledge base %q", id)
}
// The single-KB GET doesn't stamp per-user pin state, so is_pinned would
// always read false here. Enrich it from the list (the canonical pin
// source) so the field is accurate. Best-effort: a list error leaves the
// (unstamped) value rather than failing the view.
if kbs, lerr := svc.ListKnowledgeBases(ctx); lerr == nil {
for i := range kbs {
if kbs[i].ID == id {
kb.IsPinned = kbs[i].IsPinned
break
}
}
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, kb, nil)
}
// Text mode: KEY: VALUE. Nested config structs (chunking_config,
// vlm_config, etc.) are intentionally omitted from the text render —
// those are for `--format json | jq '.chunking_config'` workflows.
w := iostreams.IO.Out
fmt.Fprintf(w, "ID: %s\n", kb.ID)
fmt.Fprintf(w, "NAME: %s\n", kb.Name)
if kb.Type != "" {
fmt.Fprintf(w, "TYPE: %s\n", kb.Type)
}
if kb.Description != "" {
fmt.Fprintf(w, "DESC: %s\n", kb.Description)
}
if kb.IsPinned {
fmt.Fprintf(w, "PINNED: yes\n")
}
if kb.IsTemporary {
fmt.Fprintf(w, "TEMPORARY: yes\n")
}
fmt.Fprintf(w, "DOCS: %s\n", text.Pluralize(int(kb.KnowledgeCount), "doc"))
fmt.Fprintf(w, "CHUNKS: %s\n", text.Pluralize(int(kb.ChunkCount), "chunk"))
if kb.IsProcessing {
fmt.Fprintf(w, "PROCESSING: %s\n", text.Pluralize(int(kb.ProcessingCount), "doc"))
}
if kb.EmbeddingModelID != "" {
fmt.Fprintf(w, "EMBEDDING: %s\n", kb.EmbeddingModelID)
}
if kb.SummaryModelID != "" {
fmt.Fprintf(w, "SUMMARY MODEL: %s\n", kb.SummaryModelID)
}
if !kb.CreatedAt.IsZero() {
fmt.Fprintf(w, "CREATED: %s\n", kb.CreatedAt.Format("2006-01-02 15:04:05"))
}
if !kb.UpdatedAt.IsZero() {
// Detail page favors absolute time; FuzzyAgo is reserved for list views.
fmt.Fprintf(w, "UPDATED: %s\n", kb.UpdatedAt.Format("2006-01-02 15:04:05"))
}
return nil
}
+184
View File
@@ -0,0 +1,184 @@
package kb
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeGetSvc struct {
kb *sdk.KnowledgeBase
err error
}
func (f *fakeGetSvc) GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error) {
return f.kb, f.err
}
// ListKnowledgeBases backs is_pinned enrichment in runView. Returns the same
// KB so the enrichment finds it; nil/empty is fine (enrichment is best-effort).
func (f *fakeGetSvc) ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error) {
if f.kb == nil {
return nil, nil
}
return []sdk.KnowledgeBase{*f.kb}, nil
}
func TestGet_OK_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{
ID: "kb1", Name: "Marketing", KnowledgeCount: 12, ChunkCount: 245,
}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
t.Fatalf("runGet: %v", err)
}
got := out.String()
for _, want := range []string{"ID:", "kb1", "NAME:", "Marketing", "DOCS:", "12 docs", "CHUNKS:", "245 chunks"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
}
func TestGet_OK_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "Marketing"}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb1"); err != nil {
t.Fatalf("runGet: %v", err)
}
got := out.String()
var env struct {
OK bool `json:"ok"`
Data sdk.KnowledgeBase `json:"data"`
}
if err := json.Unmarshal([]byte(got), &env); err != nil {
t.Fatalf("parse: %v\n%s", err, got)
}
if !env.OK {
t.Errorf("envelope.ok must be true, got %q", got)
}
if env.Data.ID != "kb1" {
t.Errorf("expected id=kb1 in envelope.data, got %q", env.Data.ID)
}
}
func TestGet_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeGetSvc{err: errors.New("HTTP error 404: not found")}
err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing")
if err == nil {
t.Fatal("expected error")
}
if !cmdutil.IsNotFound(err) {
t.Errorf("expected resource.not_found, got %v", err)
}
}
// --- expanded text render: badges + extra KV lines ---
func TestView_Pinned_RendersPinnedLine(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "Pinned", IsPinned: true}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
t.Fatalf("runView: %v", err)
}
if !strings.Contains(out.String(), "PINNED:") {
t.Errorf("expected PINNED line for IsPinned=true:\n%s", out.String())
}
}
func TestView_NotPinned_OmitsPinnedLine(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "Plain", IsPinned: false}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
t.Fatalf("runView: %v", err)
}
for _, l := range strings.Split(out.String(), "\n") {
if strings.HasPrefix(l, "PINNED:") {
t.Errorf("PINNED line should be omitted when IsPinned=false: %q", l)
}
}
}
func TestView_Temporary_RendersTempLine(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb_t", Name: "Tmp", IsTemporary: true}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_t"); err != nil {
t.Fatalf("runView: %v", err)
}
if !strings.Contains(out.String(), "TEMPORARY:") {
t.Errorf("expected TEMPORARY line:\n%s", out.String())
}
}
func TestView_SummaryModel(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "X", SummaryModelID: "summary-model-x"}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if !strings.Contains(got, "SUMMARY MODEL:") || !strings.Contains(got, "summary-model-x") {
t.Errorf("expected SUMMARY MODEL line:\n%s", got)
}
}
func TestView_TypeAndSource(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "X", Type: "general", Description: "d"}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if !strings.Contains(got, "TYPE:") || !strings.Contains(got, "general") {
t.Errorf("expected TYPE line for non-empty Type:\n%s", got)
}
}
func TestView_Processing(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb_p", Name: "Busy", IsProcessing: true, ProcessingCount: 3}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_p"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if !strings.Contains(got, "PROCESSING:") || !strings.Contains(got, "3") {
t.Errorf("expected PROCESSING line with count:\n%s", got)
}
}
func TestView_NotProcessing_OmitsProcessingLine(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb_idle", Name: "Idle", IsProcessing: false}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_idle"); err != nil {
t.Fatalf("runView: %v", err)
}
for _, l := range strings.Split(out.String(), "\n") {
if strings.HasPrefix(l, "PROCESSING:") {
t.Errorf("PROCESSING line should be omitted: %q", l)
}
}
}
func TestView_CreatedAt_AlwaysRendered(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{
ID: "kb1", Name: "X",
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if !strings.Contains(got, "CREATED:") || !strings.Contains(got, "2026-01-01") {
t.Errorf("expected CREATED line:\n%s", got)
}
}