Files
Enterprise-Knowledge-Base/cli/cmd/kb/create.go
T
nullkey 6d8c8650cd feat(cli): --dry-run + risk metadata + validation parity on 19 mutations
Two intertwined agent safety nets that share the same files:

1. --dry-run flag for offline preview of mutation commands
2. Risk: metadata + SetRisk helper for destructive command surfaces

Coverage (19 mutation commands with --dry-run):
  kb.create/edit/delete           agent.create/edit/delete
  doc.create/upload/fetch/delete  doc.delete_all (special variant)
  session.delete  chunk.delete    profile.add/remove
  auth.refresh/logout             link  unlink
  api.{post,put,patch,delete}     (api.get + --dry-run rejected, exit 2)

Envelope additions (omitempty in non-dry-run paths):
  meta.dry_run: bool        true when --dry-run was used
  meta.plan:    map         {action, args} per the per-command taxonomy

Risk: metadata
--------------
cmdutil.SetRisk(cmd, action) stamps cobra.Annotations with
risk.level=destructive + risk.action=<action> on the 9 destructive
commands. The SetAgentHelp wrapper prepends a "Risk: <action>
(destructive)" line in the default text help path so agents see a clear
warning before parsing Usage. WEKNORA_AGENT_HELP=1 JSON path stays
unchanged — structured agent-help already carries warnings[].

Validation parity with the live path
------------------------------------
Every pure-local validation (flag presence, mutual exclusion, enum
bounds, URL/regex format, ResolveKBLocal for KB resolution that does not
require an SDK call) runs BEFORE the dry-run gate. This matches the
industry-standard "preview shows what live would do" contract:
--dry-run accepts exactly the same invocations the live path accepts and
rejects exactly the same invocations the live path rejects, modulo the
side-effecting work itself.

The side-effecting work (SDK calls, file writes, keyring writes, server-
side name → id resolution) is what --dry-run actually gates. Each
mutation file pairs its RunE validation block with a regression test
under *_dry_run_test.go / dryrun_validation_test.go so future refactors
don't reintroduce the gap.

Helper surface
--------------
- HandleDryRun(cmd, dryRun, plan) extracts the early-return so the
  19 RunE call sites stay 3 lines each.
- EmitDryRun routes through FormatOptions.Emit, inheriting _notice /
  TTY indent / --jq filtering for free.
- ResolveKBLocal mirrors ResolveKB but never calls the SDK; dry-run
  paths use it so the plan reports the raw --kb value (UUID or name)
  without a name → id lookup.

Streaming commands (chat, session ask, session continue-stream) are
deliberately excluded: a buffered plan makes no sense for an event
stream.

Lock semantics in the dry-run path:
  - destructive + --dry-run: exit 0, no exit-10 confirmation prompt
  - --dry-run + -y: byte-identical envelope to --dry-run alone
  - --dry-run + --jq: filter applies to the preview envelope normally
2026-05-28 19:29:50 +08:00

133 lines
4.5 KiB
Go

package kb
import (
"context"
"fmt"
"slices"
"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"
)
// 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
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"}
// 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. Same typed
// FlagError as runCreate (kept there for direct-call callers).
if opts.StorageProvider != "" {
v := strings.ToLower(strings.TrimSpace(opts.StorageProvider))
if !slices.Contains(storageProviderValues, v) {
return cmdutil.NewFlagError(fmt.Errorf(
"invalid --storage-provider %q: must be %s",
opts.StorageProvider, strings.Join(storageProviderValues, " | ")))
}
}
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
}
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 (optional; server picks default when unset)")
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)
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.StorageProvider != "" {
v := strings.ToLower(strings.TrimSpace(opts.StorageProvider))
if !slices.Contains(storageProviderValues, v) {
return cmdutil.NewFlagError(fmt.Errorf(
"invalid --storage-provider %q: must be %s",
opts.StorageProvider, strings.Join(storageProviderValues, " | ")))
}
req.StorageProviderConfig = &sdk.StorageProviderConfig{Provider: v}
}
created, err := svc.CreateKnowledgeBase(ctx, req)
if err != nil {
return cmdutil.WrapHTTP(err, "create knowledge base")
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, created, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Created knowledge base %q (id: %s)\n", created.Name, created.ID)
return nil
}