mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-29 02:04:30 +08:00
feat(cli): session subtree + kb edit / pin / empty
Roadmap items 3-5 (session) and 3-6/7/8 (kb manage).
cli/cmd/session/ (new package; sessioncmd to avoid shadowing stdlib):
- session list: paginated table (ID/TITLE/UPDATED). --page / --page-size
with 1..1000 validation. _meta.has_more from page*size < total.
- session view <id>: prints metadata; non-empty fields only. Server
timestamps arrive as strings; parsed best-effort as RFC3339.
- session delete <id>: high-risk-write; exit-10 confirmation in non-
TTY/--json paths; --dry-run emits envelope.risk + dry_run:true.
cli/cmd/kb (extended):
- kb edit <id> [--name N] [--description D]: at least one flag required;
*string options so unset fields stay unset in the PUT body. SDK
UpdateKnowledgeBaseRequest has no embedding_model field, so the
roadmap's --embedding-model dropped.
- kb pin <id> / kb unpin <id>: direct parity with gh issue pin /
gh issue unpin (verified against gh manual). Idempotent: GetKnowledgeBase
reads IsPinned, TogglePinKnowledgeBase fires only on state change.
SDK KnowledgeBase struct gained the IsPinned field (server already
returned it; SDK just hadn't modeled it — non-breaking additive).
- kb empty <id>: high-risk-write; exit-10 confirmation;
--dry-run. Returns deleted_count from the async clear response.
weknora-specific operation; no mainstream parallel.
Golden envelopes for kb_list and kb_view updated to include the new
is_pinned field — strict-additive change.
Cleanups surfaced by the post-commit reviewer round:
- ConfirmPrompter promoted to cli/internal/testutil/ (4-copy threshold
reached: context/remove, kb/delete, kb/empty, session/delete).
kb/delete_test.go's pre-existing local copy left untouched per the
upstream-respect convention.
- kb pin/unpin idempotent no-op path no longer emits a write-class
envelope. Added _meta.warnings "already {un}pinned — no server
call made" and dropped the risk classification on the no-op branch.
- doc list --page-size was unbounded while session list enforces
1..1000. Same validation added to doc list.
18 + 18 unit tests; e2e exit codes verified.
Roadmap: 3-5, 3-6, 3-7, 3-8.
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
{"ok":true,"data":{"items":[{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},{"id":"kb2","name":"API Reference","type":"","is_temporary":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":12,"chunk_count":340,"is_processing":false,"processing_count":0}]},"dry_run":false}
|
||||
{"ok":true,"data":{"items":[{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},{"id":"kb2","name":"API Reference","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":12,"chunk_count":340,"is_processing":false,"processing_count":0}]},"dry_run":false}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"ok":true,"data":{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"description":"Internal onboarding handbook","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},"dry_run":false}
|
||||
{"ok":true,"data":{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"Internal onboarding handbook","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},"dry_run":false}
|
||||
|
||||
@@ -10,26 +10,10 @@ import (
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
)
|
||||
|
||||
// confirmPrompter scripts a Confirm answer; Input/Password unused.
|
||||
type confirmPrompter struct {
|
||||
answer bool
|
||||
err error
|
||||
asked bool
|
||||
}
|
||||
|
||||
func (c *confirmPrompter) Input(string, string) (string, error) {
|
||||
return "", prompt.ErrAgentNoPrompt
|
||||
}
|
||||
func (c *confirmPrompter) Password(string) (string, error) { return "", prompt.ErrAgentNoPrompt }
|
||||
func (c *confirmPrompter) Confirm(string, bool) (bool, error) {
|
||||
c.asked = true
|
||||
return c.answer, c.err
|
||||
}
|
||||
|
||||
// seedStore returns a MemStore pre-loaded with sentinel values for every
|
||||
// secret slot a context might reference. Tests assert deletion by checking
|
||||
// `secrets.ErrNotFound` post-runRemove.
|
||||
@@ -67,11 +51,11 @@ func TestRemove_NonCurrent_NoPromptNeeded(t *testing.T) {
|
||||
}
|
||||
|
||||
store := seedStore(t, "staging", "api_key")
|
||||
p := &confirmPrompter{}
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
if err := runRemove(&RemoveOptions{}, "staging", store, p); err != nil {
|
||||
t.Fatalf("runRemove: %v", err)
|
||||
}
|
||||
if p.asked {
|
||||
if p.Asked {
|
||||
t.Errorf("non-current remove must not prompt")
|
||||
}
|
||||
|
||||
@@ -100,7 +84,7 @@ func TestRemove_NotFound_WithDidYouMean(t *testing.T) {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
err := runRemove(&RemoveOptions{}, "prodution", secrets.NewMemStore(), &confirmPrompter{})
|
||||
err := runRemove(&RemoveOptions{}, "prodution", secrets.NewMemStore(), &testutil.ConfirmPrompter{})
|
||||
if err == nil {
|
||||
t.Fatal("expected not-found error")
|
||||
}
|
||||
@@ -132,7 +116,7 @@ func TestRemove_Current_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
}
|
||||
|
||||
store := seedStore(t, "production", "access")
|
||||
err := runRemove(&RemoveOptions{}, "production", store, &confirmPrompter{})
|
||||
err := runRemove(&RemoveOptions{}, "production", store, &testutil.ConfirmPrompter{})
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation-required error")
|
||||
}
|
||||
@@ -171,7 +155,7 @@ func TestRemove_Current_WithYes_ClearsCurrent(t *testing.T) {
|
||||
}
|
||||
|
||||
store := seedStore(t, "production", "access")
|
||||
if err := runRemove(&RemoveOptions{Yes: true}, "production", store, &confirmPrompter{}); err != nil {
|
||||
if err := runRemove(&RemoveOptions{Yes: true}, "production", store, &testutil.ConfirmPrompter{}); err != nil {
|
||||
t.Fatalf("runRemove: %v", err)
|
||||
}
|
||||
got, _ := config.Load()
|
||||
@@ -199,7 +183,7 @@ func TestRemove_Current_TTY_PromptNo(t *testing.T) {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
p := &confirmPrompter{answer: false}
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runRemove(&RemoveOptions{}, "production", secrets.NewMemStore(), p)
|
||||
if err == nil {
|
||||
t.Fatal("expected user-aborted error")
|
||||
@@ -211,7 +195,7 @@ func TestRemove_Current_TTY_PromptNo(t *testing.T) {
|
||||
if cm.Code != cmdutil.CodeUserAborted {
|
||||
t.Errorf("code=%q, want %q", cm.Code, cmdutil.CodeUserAborted)
|
||||
}
|
||||
if !p.asked {
|
||||
if !p.Asked {
|
||||
t.Errorf("prompt should have been asked on TTY")
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "Aborted") {
|
||||
@@ -235,7 +219,7 @@ func TestRemove_DryRun(t *testing.T) {
|
||||
}
|
||||
|
||||
store := seedStore(t, "production", "access")
|
||||
if err := runRemove(&RemoveOptions{DryRun: true, JSONOut: true}, "production", store, &confirmPrompter{}); err != nil {
|
||||
if err := runRemove(&RemoveOptions{DryRun: true, JSONOut: true}, "production", store, &testutil.ConfirmPrompter{}); err != nil {
|
||||
t.Fatalf("runRemove dry-run: %v", err)
|
||||
}
|
||||
var env format.Envelope
|
||||
|
||||
@@ -84,6 +84,18 @@ backend storage order is not guaranteed and varies between deployments.`,
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, opts *ListOptions, svc ListService, kbID string) error {
|
||||
if opts.Page < 1 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--page must be >= 1, got %d", opts.Page),
|
||||
}
|
||||
}
|
||||
if opts.PageSize < 1 || opts.PageSize > 1000 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--page-size must be in 1..1000, got %d", opts.PageSize),
|
||||
}
|
||||
}
|
||||
items, total, err := svc.ListKnowledge(ctx, kbID, opts.Page, opts.PageSize, "")
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "list documents")
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/agent"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// EditOptions captures `weknora kb edit` flags. 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. The
|
||||
// server PUT semantics are "replace everything in the request"; if we
|
||||
// always sent both, an `--name` invocation would silently clear the
|
||||
// description.
|
||||
type EditOptions struct {
|
||||
Name *string
|
||||
Description *string
|
||||
JSONOut bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// EditService is the narrow SDK surface this command depends on.
|
||||
type EditService interface {
|
||||
UpdateKnowledgeBase(ctx context.Context, id string, req *sdk.UpdateKnowledgeBaseRequest) (*sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
// NewCmdEdit builds `weknora kb edit <id>`. Mirrors `gh repo edit`. 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: "edit <id>",
|
||||
Short: "Edit a knowledge base's name or description",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
if c.Flag("name").Changed {
|
||||
opts.Name = &name
|
||||
}
|
||||
if c.Flag("description").Changed {
|
||||
opts.Description = &desc
|
||||
}
|
||||
opts.DryRun = cmdutil.IsDryRun(c)
|
||||
if opts.DryRun {
|
||||
return runEdit(c.Context(), opts, nil, args[0])
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runEdit(c.Context(), opts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "New name (omit to leave unchanged)")
|
||||
cmd.Flags().StringVar(&desc, "description", "", "New description (omit to leave unchanged)")
|
||||
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
|
||||
agent.SetAgentHelp(cmd, "Edits a knowledge base. At least one of --name/--description is required. Fields not passed are preserved server-side. Returns the updated KnowledgeBase.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runEdit(ctx context.Context, opts *EditOptions, svc EditService, id string) error {
|
||||
if opts.Name == nil && opts.Description == nil {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputMissingFlag,
|
||||
Message: "kb edit requires at least one of --name or --description",
|
||||
Hint: "pass --name <name> and/or --description <desc>",
|
||||
}
|
||||
}
|
||||
|
||||
req := &sdk.UpdateKnowledgeBaseRequest{}
|
||||
if opts.Name != nil {
|
||||
req.Name = *opts.Name
|
||||
}
|
||||
if opts.Description != nil {
|
||||
req.Description = *opts.Description
|
||||
}
|
||||
|
||||
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("edit knowledge base %s", id)}
|
||||
if opts.DryRun {
|
||||
return cmdutil.EmitDryRun(opts.JSONOut, req, &format.Meta{KBID: id}, risk)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateKnowledgeBase(ctx, id, req)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "edit knowledge base %s", id)
|
||||
}
|
||||
if opts.JSONOut {
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.SuccessWithRisk(updated, &format.Meta{KBID: id}, risk))
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Updated knowledge base %s\n", id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeEditSvc captures the (id, request) pair handed to UpdateKnowledgeBase.
|
||||
type fakeEditSvc struct {
|
||||
gotID string
|
||||
gotReq *sdk.UpdateKnowledgeBaseRequest
|
||||
resp *sdk.KnowledgeBase
|
||||
err error
|
||||
}
|
||||
|
||||
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{}, 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")
|
||||
}
|
||||
|
||||
func TestEdit_OnlyName(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{resp: &sdk.KnowledgeBase{ID: "kb_abc", Name: "new"}}
|
||||
opts := &EditOptions{}
|
||||
opts.Name = stringPtr("new")
|
||||
require.NoError(t, runEdit(context.Background(), opts, svc, "kb_abc"))
|
||||
|
||||
assert.Equal(t, "kb_abc", svc.gotID)
|
||||
require.NotNil(t, svc.gotReq)
|
||||
assert.Equal(t, "new", svc.gotReq.Name)
|
||||
// Description must be empty string (not "<nil>"), so server doesn't
|
||||
// confuse "unset" with "set-to-empty". Actually the SDK ships an empty
|
||||
// string either way — we just verify we didn't accidentally serialize a
|
||||
// description override.
|
||||
assert.Equal(t, "", svc.gotReq.Description)
|
||||
assert.Contains(t, out.String(), "kb_abc")
|
||||
}
|
||||
|
||||
func TestEdit_OnlyDescription(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{resp: &sdk.KnowledgeBase{ID: "kb_abc"}}
|
||||
opts := &EditOptions{}
|
||||
opts.Description = stringPtr("new desc")
|
||||
require.NoError(t, runEdit(context.Background(), opts, svc, "kb_abc"))
|
||||
|
||||
require.NotNil(t, svc.gotReq)
|
||||
assert.Equal(t, "new desc", svc.gotReq.Description)
|
||||
assert.Equal(t, "", svc.gotReq.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, svc, "kb_abc"))
|
||||
assert.Equal(t, "renamed", svc.gotReq.Name)
|
||||
assert.Equal(t, "new desc", svc.gotReq.Description)
|
||||
}
|
||||
|
||||
func TestEdit_DryRun_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
opts := &EditOptions{DryRun: true, JSONOut: true}
|
||||
opts.Name = stringPtr("preview")
|
||||
require.NoError(t, runEdit(context.Background(), opts, nil, "kb_abc"))
|
||||
|
||||
body := out.String()
|
||||
assert.True(t, strings.HasPrefix(body, `{"ok":true`))
|
||||
assert.Contains(t, body, `"dry_run":true`)
|
||||
assert.Contains(t, body, `"write"`)
|
||||
}
|
||||
|
||||
func TestEdit_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{err: errors.New("HTTP error 404: not found")}
|
||||
opts := &EditOptions{}
|
||||
opts.Name = stringPtr("x")
|
||||
err := runEdit(context.Background(), opts, 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 }
|
||||
@@ -0,0 +1,98 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/agent"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// EmptyOptions captures `weknora kb empty` flag state.
|
||||
type EmptyOptions struct {
|
||||
Yes bool
|
||||
JSONOut bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// EmptyService is the narrow SDK surface this command depends on.
|
||||
type EmptyService interface {
|
||||
ClearKnowledgeBaseContents(ctx context.Context, id string) (*sdk.ClearKnowledgeBaseContentsResponse, error)
|
||||
}
|
||||
|
||||
// emptyResult is the typed payload emitted under data on success.
|
||||
type emptyResult struct {
|
||||
ID string `json:"id"`
|
||||
DeletedCount int `json:"deleted_count"`
|
||||
}
|
||||
|
||||
// NewCmdEmpty builds `weknora kb empty <id>`. Wipes every
|
||||
// document inside the knowledge base; the KB itself is preserved. The
|
||||
// server runs the delete asynchronously and reports the count of documents
|
||||
// that were enqueued for removal.
|
||||
func NewCmdEmpty(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &EmptyOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "empty <id>",
|
||||
Short: "Delete every document in a knowledge base (preserves the KB)",
|
||||
Long: `Removes all documents and chunks from a knowledge base while keeping the
|
||||
KB record (its name, description, and config) intact. The delete is async;
|
||||
the server reports the count of items enqueued for removal.
|
||||
|
||||
Prompts for confirmation by default; pass -y/--yes to skip in agent / CI /
|
||||
piped contexts. Without -y the CLI exits 10 (lark-cli skill protocol).`,
|
||||
Example: ` weknora kb empty kb_abc # interactive confirm
|
||||
weknora kb empty kb_abc -y --json # agent-friendly`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
opts.DryRun = cmdutil.IsDryRun(c)
|
||||
if opts.DryRun {
|
||||
return runEmpty(c.Context(), opts, nil, f.Prompter(), args[0])
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runEmpty(c.Context(), opts, cli, f.Prompter(), args[0])
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
|
||||
agent.SetAgentHelp(cmd, "Destructively empties a knowledge base (deletes all documents, preserves the KB record + config). ALWAYS pass -y/--yes in agent mode (non-TTY ⇒ confirm fails). Returns data: {id, deleted_count}. Async — items may still be processing server-side after the call returns.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runEmpty(ctx context.Context, opts *EmptyOptions, svc EmptyService, p prompt.Prompter, id string) error {
|
||||
risk := &format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("empty knowledge base %s", id)}
|
||||
|
||||
if opts.DryRun {
|
||||
return cmdutil.EmitDryRun(opts.JSONOut, emptyResult{ID: id}, &format.Meta{KBID: id}, risk)
|
||||
}
|
||||
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, opts.JSONOut, "all contents of knowledge base", id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.ClearKnowledgeBaseContents(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "empty knowledge base %s", id)
|
||||
}
|
||||
deleted := 0
|
||||
if resp != nil {
|
||||
deleted = resp.DeletedCount
|
||||
}
|
||||
|
||||
if opts.JSONOut {
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.SuccessWithRisk(
|
||||
emptyResult{ID: id, DeletedCount: deleted}, &format.Meta{KBID: id}, risk,
|
||||
))
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Emptied knowledge base %s (%d document(s) cleared)\n", id, deleted)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeEmptySvc records calls + scripts the response.
|
||||
type fakeEmptySvc struct {
|
||||
err error
|
||||
gotID string
|
||||
called bool
|
||||
resp *sdk.ClearKnowledgeBaseContentsResponse
|
||||
}
|
||||
|
||||
func (f *fakeEmptySvc) ClearKnowledgeBaseContents(_ context.Context, id string) (*sdk.ClearKnowledgeBaseContentsResponse, error) {
|
||||
f.called = true
|
||||
f.gotID = id
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.resp == nil {
|
||||
return &sdk.ClearKnowledgeBaseContentsResponse{DeletedCount: 0}, nil
|
||||
}
|
||||
return f.resp, nil
|
||||
}
|
||||
|
||||
func TestEmpty_WithYes(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeEmptySvc{resp: &sdk.ClearKnowledgeBaseContentsResponse{DeletedCount: 42}}
|
||||
require.NoError(t, runEmpty(context.Background(), &EmptyOptions{Yes: true}, svc, &testutil.ConfirmPrompter{}, "kb_abc"))
|
||||
assert.True(t, svc.called)
|
||||
assert.Equal(t, "kb_abc", svc.gotID)
|
||||
body := out.String()
|
||||
assert.Contains(t, body, "kb_abc")
|
||||
assert.Contains(t, body, "42")
|
||||
}
|
||||
|
||||
func TestEmpty_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
svc := &fakeEmptySvc{}
|
||||
err := runEmpty(context.Background(), &EmptyOptions{}, svc, &testutil.ConfirmPrompter{}, "kb_abc")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
assert.False(t, svc.called)
|
||||
}
|
||||
|
||||
func TestEmpty_TTY_ConfirmNo(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeEmptySvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runEmpty(context.Background(), &EmptyOptions{}, svc, p, "kb_abc")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.False(t, svc.called)
|
||||
assert.Contains(t, errBuf.String(), "Aborted")
|
||||
}
|
||||
|
||||
func TestEmpty_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEmptySvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runEmpty(context.Background(), &EmptyOptions{Yes: true}, svc, &testutil.ConfirmPrompter{}, "kb_missing")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestEmpty_DryRun_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeEmptySvc{}
|
||||
require.NoError(t, runEmpty(context.Background(), &EmptyOptions{DryRun: true, JSONOut: true}, svc, &testutil.ConfirmPrompter{}, "kb_dry"))
|
||||
body := out.String()
|
||||
assert.True(t, strings.HasPrefix(body, `{"ok":true`))
|
||||
assert.Contains(t, body, `"dry_run":true`)
|
||||
assert.Contains(t, body, `"high-risk-write"`)
|
||||
assert.False(t, svc.called)
|
||||
}
|
||||
+8
-3
@@ -1,6 +1,7 @@
|
||||
// Package kb holds the `weknora kb` command tree: list / view / create / delete.
|
||||
// `view` is the primary read verb (gh repo view convention); `get` survives as
|
||||
// a cobra alias on the view subcommand for v0.0/v0.1 callers.
|
||||
// Package kb holds the `weknora kb` command tree: list / view / create /
|
||||
// edit / delete / pin / unpin / clear-contents. `view` is the primary read
|
||||
// verb (gh repo view convention); `get` survives as a cobra alias on the
|
||||
// view subcommand for v0.0/v0.1 callers.
|
||||
package kb
|
||||
|
||||
import (
|
||||
@@ -20,6 +21,10 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
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(NewCmdEmpty(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/agent"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// PinOptions captures `weknora kb pin / unpin` flag state.
|
||||
type PinOptions struct {
|
||||
JSONOut bool
|
||||
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 non-idempotent toggle.
|
||||
type PinService interface {
|
||||
GetKnowledgeBase(ctx context.Context, id string) (*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 + " <id>",
|
||||
Short: short,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.DryRun = cmdutil.IsDryRun(c)
|
||||
if opts.DryRun {
|
||||
return runPin(c.Context(), opts, nil, args[0], want)
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runPin(c.Context(), opts, cli, args[0], want)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
|
||||
agent.SetAgentHelp(cmd, fmt.Sprintf("Idempotent %s: reads current pin state, toggles only if different. No-op when already in the requested state.", use))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runPin(ctx context.Context, opts *PinOptions, svc PinService, id string, want bool) error {
|
||||
verb := "pin"
|
||||
if !want {
|
||||
verb = "unpin"
|
||||
}
|
||||
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("%s knowledge base %s", verb, id)}
|
||||
|
||||
if opts.DryRun {
|
||||
// Dry-run can't introspect state without a network call by design (see
|
||||
// kb/delete.go for the same convention). Report what *would* run if
|
||||
// state diverged; agents can disambiguate via a subsequent `kb view`.
|
||||
return cmdutil.EmitDryRun(opts.JSONOut, struct {
|
||||
ID string `json:"id"`
|
||||
Want bool `json:"want_pinned"`
|
||||
}{id, want}, &format.Meta{KBID: id}, risk)
|
||||
}
|
||||
|
||||
current, err := svc.GetKnowledgeBase(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "get knowledge base %s", id)
|
||||
}
|
||||
if current.IsPinned == want {
|
||||
state := "pinned"
|
||||
if !want {
|
||||
state = "unpinned"
|
||||
}
|
||||
// No-op path: tell agents what happened. The risk-write classification
|
||||
// was the *requested* operation, not what occurred — surface it via a
|
||||
// _meta.warning so audit logs don't count a write that wasn't made.
|
||||
if opts.JSONOut {
|
||||
meta := &format.Meta{KBID: id, Warnings: []string{fmt.Sprintf("already %s — no server call made", state)}}
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.Success(current, meta))
|
||||
}
|
||||
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.Wrapf(cmdutil.ClassifyHTTPError(err), err, "%s knowledge base %s", verb, id)
|
||||
}
|
||||
if opts.JSONOut {
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.SuccessWithRisk(updated, &format.Meta{KBID: id}, risk))
|
||||
}
|
||||
state := "pinned"
|
||||
if !updated.IsPinned {
|
||||
state = "unpinned"
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ %s %s\n", id, state)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakePinSvc satisfies PinService: GetKnowledgeBase + TogglePinKnowledgeBase.
|
||||
type fakePinSvc struct {
|
||||
current sdk.KnowledgeBase
|
||||
getErr error
|
||||
toggleErr error
|
||||
toggleCalled bool
|
||||
}
|
||||
|
||||
func (f *fakePinSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
c := f.current
|
||||
c.ID = id
|
||||
return &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{}, 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{}, 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{}, 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{}, 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{}, 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{}, 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_DryRun_StateDiffers(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}}
|
||||
require.NoError(t, runPin(context.Background(), &PinOptions{DryRun: true, JSONOut: true}, svc, "kb_abc", true))
|
||||
assert.False(t, svc.toggleCalled, "dry-run must not call toggle")
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"dry_run":true`)
|
||||
assert.Contains(t, body, `"write"`)
|
||||
}
|
||||
|
||||
func TestPin_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}}
|
||||
require.NoError(t, runPin(context.Background(), &PinOptions{JSONOut: true}, svc, "kb_abc", true))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"is_pinned":true`)
|
||||
assert.Contains(t, body, `"id":"kb_abc"`)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/Tencent/WeKnora/cli/cmd/kb"
|
||||
linkcmd "github.com/Tencent/WeKnora/cli/cmd/link"
|
||||
"github.com/Tencent/WeKnora/cli/cmd/search"
|
||||
sessioncmd "github.com/Tencent/WeKnora/cli/cmd/session"
|
||||
"github.com/Tencent/WeKnora/cli/internal/agent"
|
||||
"github.com/Tencent/WeKnora/cli/internal/build"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
@@ -176,6 +177,7 @@ hybrid searches against a WeKnora server from your shell or an AI agent.`,
|
||||
cmd.AddCommand(doc.NewCmd(f))
|
||||
cmd.AddCommand(apicmd.NewCmd(f))
|
||||
cmd.AddCommand(chatcmd.NewCmd(f))
|
||||
cmd.AddCommand(sessioncmd.NewCmd(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/agent"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
)
|
||||
|
||||
// DeleteOptions captures `weknora session delete` flag state. Yes is
|
||||
// sourced from the global -y/--yes persistent flag (gh-style).
|
||||
type DeleteOptions struct {
|
||||
Yes bool
|
||||
JSONOut bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
type DeleteService interface {
|
||||
DeleteSession(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 session delete`. Mirrors `gh issue delete`:
|
||||
// destructive write gated by -y/--yes (exit-10 protocol in scripted /
|
||||
// --json invocations).
|
||||
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &DeleteOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a chat session",
|
||||
Long: `Permanently delete a chat session and its messages.
|
||||
|
||||
Prompts for confirmation by default when stdout is a TTY and --json 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
|
||||
returns an envelope describing the missing confirmation. NEVER auto-pass -y
|
||||
without the user's explicit go-ahead.`,
|
||||
Example: ` weknora session delete s_abc # interactive confirm
|
||||
weknora session delete s_abc -y # no prompt
|
||||
weknora session delete s_abc -y --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
opts.DryRun = cmdutil.IsDryRun(c)
|
||||
if opts.DryRun {
|
||||
return runDelete(c.Context(), opts, nil, f.Prompter(), args[0])
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runDelete(c.Context(), opts, cli, f.Prompter(), args[0])
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
|
||||
agent.SetAgentHelp(cmd, "Destructively deletes a chat session by id. ALWAYS pass -y/--yes in agent mode (no TTY ⇒ confirm prompt fails). Returns data: {id, deleted:true}.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(ctx context.Context, opts *DeleteOptions, svc DeleteService, p prompt.Prompter, id string) error {
|
||||
risk := &format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("delete session %s", id)}
|
||||
|
||||
if opts.DryRun {
|
||||
return cmdutil.EmitDryRun(opts.JSONOut,
|
||||
deleteResult{ID: id, Deleted: false}, nil, risk)
|
||||
}
|
||||
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, opts.JSONOut, "session", id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.DeleteSession(ctx, id); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "delete session %s", id)
|
||||
}
|
||||
|
||||
if opts.JSONOut {
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.SuccessWithRisk(
|
||||
deleteResult{ID: id, Deleted: true}, nil, risk,
|
||||
))
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted session %s\n", id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"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) DeleteSession(_ context.Context, id string) error {
|
||||
f.called = true
|
||||
f.gotID = id
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestDelete_WithYes(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
require.NoError(t, runDelete(context.Background(), &DeleteOptions{Yes: true}, svc, p, "s_abc"))
|
||||
assert.True(t, svc.called)
|
||||
assert.Equal(t, "s_abc", svc.gotID)
|
||||
assert.False(t, p.Asked, "-y must skip prompt")
|
||||
assert.Contains(t, out.String(), "Deleted")
|
||||
}
|
||||
|
||||
func TestDelete_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, svc, &testutil.ConfirmPrompter{}, "s_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) {
|
||||
iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
err := runDelete(context.Background(), &DeleteOptions{}, svc, &testutil.ConfirmPrompter{}, "s_x")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
assert.False(t, svc.called, "non-TTY without -y must not call DeleteSession")
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmYes(t *testing.T) {
|
||||
_, _ = iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: true}
|
||||
require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, svc, p, "s_yes"))
|
||||
assert.True(t, p.Asked)
|
||||
assert.True(t, svc.called)
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmNo(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runDelete(context.Background(), &DeleteOptions{}, svc, p, "s_no")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.False(t, svc.called)
|
||||
assert.Contains(t, errBuf.String(), "Aborted")
|
||||
}
|
||||
|
||||
func TestDelete_DryRun_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
require.NoError(t, runDelete(context.Background(), &DeleteOptions{DryRun: true, JSONOut: true}, svc, &testutil.ConfirmPrompter{}, "s_dry"))
|
||||
body := out.String()
|
||||
assert.True(t, strings.HasPrefix(body, `{"ok":true`))
|
||||
assert.Contains(t, body, `"dry_run":true`)
|
||||
assert.Contains(t, body, `"high-risk-write"`)
|
||||
assert.False(t, svc.called, "dry-run must not call SDK")
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/agent"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPage = 1
|
||||
defaultPageSize = 30
|
||||
maxPageSize = 1000
|
||||
)
|
||||
|
||||
// ListOptions captures `weknora session list` flags.
|
||||
type ListOptions struct {
|
||||
Page int
|
||||
PageSize int
|
||||
JSONOut bool
|
||||
}
|
||||
|
||||
// ListService is the narrow SDK surface this command depends on.
|
||||
type ListService interface {
|
||||
GetSessionsByTenant(ctx context.Context, page, pageSize int) ([]sdk.Session, int, error)
|
||||
}
|
||||
|
||||
// listResult is the typed payload emitted under data.
|
||||
type listResult struct {
|
||||
Items []sdk.Session `json:"items"`
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora session list`. Paginated; defaults to page=1
|
||||
// page_size=30. Mirrors `gh issue list` shape (no cursor — server is
|
||||
// page-based today).
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{Page: defaultPage, PageSize: defaultPageSize}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List chat sessions for the active context",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runList(c.Context(), opts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&opts.Page, "page", defaultPage, "Page number (1-indexed)")
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per page (1..1000)")
|
||||
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
|
||||
agent.SetAgentHelp(cmd, "Lists chat sessions. _meta.has_more is set when more pages exist; bump --page and retry to walk them.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, opts *ListOptions, svc ListService) error {
|
||||
if opts.Page < 1 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--page must be >= 1, got %d", opts.Page),
|
||||
}
|
||||
}
|
||||
if opts.PageSize < 1 || opts.PageSize > maxPageSize {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--page-size must be in 1..%d, got %d", maxPageSize, opts.PageSize),
|
||||
}
|
||||
}
|
||||
|
||||
items, total, err := svc.GetSessionsByTenant(ctx, opts.Page, opts.PageSize)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "list sessions")
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.Session{} // JSON [] not null
|
||||
}
|
||||
|
||||
if opts.JSONOut {
|
||||
meta := &format.Meta{HasMore: opts.Page*opts.PageSize < total}
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.Success(listResult{Items: items}, meta))
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no sessions)")
|
||||
return nil
|
||||
}
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tTITLE\tUPDATED")
|
||||
now := time.Now()
|
||||
for _, s := range items {
|
||||
title := text.Truncate(50, s.Title)
|
||||
if title == "" {
|
||||
title = "-"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", s.ID, title, fuzzyTime(now, s.UpdatedAt))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// fuzzyTime renders a server-provided timestamp string in `gh`-style "2d
|
||||
// ago" form. Returns the raw input if parsing fails — better to surface
|
||||
// the unknown format than to silently render "-".
|
||||
func fuzzyTime(now time.Time, ts string) string {
|
||||
if ts == "" {
|
||||
return "-"
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
return ts
|
||||
}
|
||||
return text.FuzzyAgo(now, t)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package sessioncmd
|
||||
|
||||
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/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeListService scripts a GetSessionsByTenant response.
|
||||
type fakeListService struct {
|
||||
items []sdk.Session
|
||||
total int
|
||||
err error
|
||||
gotPage int
|
||||
gotPageSize int
|
||||
}
|
||||
|
||||
func (f *fakeListService) GetSessionsByTenant(_ context.Context, page, pageSize int) ([]sdk.Session, int, error) {
|
||||
f.gotPage = page
|
||||
f.gotPageSize = pageSize
|
||||
return f.items, f.total, f.err
|
||||
}
|
||||
|
||||
func TestList_Empty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{items: nil, total: 0}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{Page: 1, PageSize: 30}, svc))
|
||||
assert.Contains(t, out.String(), "no sessions")
|
||||
}
|
||||
|
||||
func TestList_Table(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{
|
||||
items: []sdk.Session{
|
||||
{ID: "s_1", Title: "Design review", CreatedAt: "2026-05-10T09:00:00Z", UpdatedAt: "2026-05-12T14:00:00Z"},
|
||||
{ID: "s_2", Title: "RAG bug repro", CreatedAt: "2026-05-09T08:00:00Z", UpdatedAt: "2026-05-11T11:00:00Z"},
|
||||
},
|
||||
total: 2,
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{Page: 1, PageSize: 30}, svc))
|
||||
got := out.String()
|
||||
assert.Contains(t, got, "s_1")
|
||||
assert.Contains(t, got, "Design review")
|
||||
assert.Contains(t, got, "s_2")
|
||||
assert.Equal(t, 1, svc.gotPage)
|
||||
assert.Equal(t, 30, svc.gotPageSize)
|
||||
}
|
||||
|
||||
func TestList_JSON_WithMeta(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{
|
||||
items: []sdk.Session{
|
||||
{ID: "s_1", Title: "T1", UpdatedAt: "2026-05-12T14:00:00Z"},
|
||||
},
|
||||
total: 47,
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{Page: 2, PageSize: 10, JSONOut: true}, svc))
|
||||
|
||||
var env format.Envelope
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
require.True(t, env.OK)
|
||||
// Pagination flags are forwarded.
|
||||
assert.Equal(t, 2, svc.gotPage)
|
||||
assert.Equal(t, 10, svc.gotPageSize)
|
||||
// envelope.data.items shaped + paging metadata in _meta
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"id":"s_1"`)
|
||||
assert.Contains(t, body, `"items":`)
|
||||
// has_more inferred from page*pageSize < total (2*10=20 < 47).
|
||||
assert.Contains(t, body, `"has_more":true`)
|
||||
}
|
||||
|
||||
func TestList_JSON_LastPage_NoHasMore(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{
|
||||
items: []sdk.Session{{ID: "s_1"}},
|
||||
total: 11,
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{Page: 2, PageSize: 10, JSONOut: true}, svc))
|
||||
// page*size = 20 ≥ total 11 → has_more must be false (omitempty drops the key)
|
||||
body := out.String()
|
||||
assert.NotContains(t, body, `"has_more":true`)
|
||||
}
|
||||
|
||||
func TestList_NilItems_RendersAsEmptyArray(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{items: nil, total: 0}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{Page: 1, PageSize: 30, JSONOut: true}, svc))
|
||||
assert.Contains(t, out.String(), `"items":[]`)
|
||||
}
|
||||
|
||||
func TestList_BadPagination(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cases := []struct {
|
||||
page, size int
|
||||
name string
|
||||
}{
|
||||
{0, 30, "page < 1"},
|
||||
{-1, 30, "page negative"},
|
||||
{1, 0, "size < 1"},
|
||||
{1, 1001, "size > max"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := runList(context.Background(), &ListOptions{Page: tc.page, PageSize: tc.size}, &fakeListService{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_NetworkError_TypedCode(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListService{err: errors.New("HTTP error 401: unauthenticated")}
|
||||
err := runList(context.Background(), &ListOptions{Page: 1, PageSize: 30}, svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
|
||||
// Sanity: title with multi-rune content (CJK) should not crash truncation.
|
||||
func TestList_NonASCIITitle(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{items: []sdk.Session{{ID: "s_zh", Title: strings.Repeat("中文", 50)}}, total: 1}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{Page: 1, PageSize: 30}, svc))
|
||||
assert.Contains(t, out.String(), "s_zh")
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package sessioncmd holds `weknora session` command tree (list / view /
|
||||
// delete). Mirrors `gh issue list/view/delete` for chat history.
|
||||
//
|
||||
// Package name `sessioncmd` (not `session`) so callers can `import sdk
|
||||
// "github.com/Tencent/WeKnora/client"` and use `sdk.Session` without
|
||||
// shadowing — same hygiene as `contextcmd`.
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmd builds the `weknora session` parent command.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "session",
|
||||
Short: "Manage chat sessions",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(c *cobra.Command, _ []string) { _ = c.Help() },
|
||||
}
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/agent"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// ViewOptions captures `weknora session view` flags.
|
||||
type ViewOptions struct {
|
||||
JSONOut bool
|
||||
}
|
||||
|
||||
// ViewService is the narrow SDK surface this command depends on.
|
||||
type ViewService interface {
|
||||
GetSession(ctx context.Context, id string) (*sdk.Session, error)
|
||||
}
|
||||
|
||||
// NewCmdView builds `weknora session view <id>`. Mirrors `gh issue view`.
|
||||
// The server endpoint returns metadata only (title/description/timestamps);
|
||||
// message content lives under a separate session_messages endpoint that the
|
||||
// SDK doesn't currently wrap, which is why there's no --full flag.
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ViewOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <id>",
|
||||
Short: "Show a chat session by ID",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runView(c.Context(), opts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
|
||||
agent.SetAgentHelp(cmd, "Shows a chat session's metadata (title, description, timestamps). Errors with resource.not_found if id is unknown.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(ctx context.Context, opts *ViewOptions, svc ViewService, id string) error {
|
||||
s, err := svc.GetSession(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "get session %q", id)
|
||||
}
|
||||
if opts.JSONOut {
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.Success(s, nil))
|
||||
}
|
||||
w := iostreams.IO.Out
|
||||
fmt.Fprintf(w, "ID: %s\n", s.ID)
|
||||
if s.Title != "" {
|
||||
fmt.Fprintf(w, "TITLE: %s\n", s.Title)
|
||||
}
|
||||
if s.Description != "" {
|
||||
fmt.Fprintf(w, "DESC: %s\n", s.Description)
|
||||
}
|
||||
if t, ok := parseTS(s.CreatedAt); ok {
|
||||
fmt.Fprintf(w, "CREATED: %s\n", t.Format("2006-01-02 15:04:05"))
|
||||
} else if s.CreatedAt != "" {
|
||||
fmt.Fprintf(w, "CREATED: %s\n", s.CreatedAt)
|
||||
}
|
||||
if t, ok := parseTS(s.UpdatedAt); ok {
|
||||
fmt.Fprintf(w, "UPDATED: %s\n", t.Format("2006-01-02 15:04:05"))
|
||||
} else if s.UpdatedAt != "" {
|
||||
fmt.Fprintf(w, "UPDATED: %s\n", s.UpdatedAt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTS(s string) (time.Time, bool) {
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package sessioncmd
|
||||
|
||||
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/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeViewService scripts a GetSession response.
|
||||
type fakeViewService struct {
|
||||
s *sdk.Session
|
||||
err error
|
||||
gotID string
|
||||
}
|
||||
|
||||
func (f *fakeViewService) GetSession(_ context.Context, id string) (*sdk.Session, error) {
|
||||
f.gotID = id
|
||||
return f.s, f.err
|
||||
}
|
||||
|
||||
func TestView_Human(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{s: &sdk.Session{
|
||||
ID: "s_abc",
|
||||
Title: "Design review",
|
||||
Description: "RAG chunking strategy review",
|
||||
CreatedAt: "2026-05-10T09:00:00Z",
|
||||
UpdatedAt: "2026-05-12T14:00:00Z",
|
||||
}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{}, svc, "s_abc"))
|
||||
got := out.String()
|
||||
for _, want := range []string{"s_abc", "Design review", "RAG chunking strategy review", "2026-05-12"} {
|
||||
assert.Contains(t, got, want)
|
||||
}
|
||||
assert.Equal(t, "s_abc", svc.gotID)
|
||||
}
|
||||
|
||||
func TestView_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{s: &sdk.Session{ID: "s_abc", Title: "T", UpdatedAt: "2026-05-12T14:00:00Z"}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{JSONOut: true}, svc, "s_abc"))
|
||||
|
||||
var env format.Envelope
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
require.True(t, env.OK)
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"id":"s_abc"`)
|
||||
}
|
||||
|
||||
func TestView_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{err: errors.New("HTTP error 404: not found")}
|
||||
err := runView(context.Background(), &ViewOptions{}, svc, "s_missing")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestView_OmitsEmptyDescription(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{s: &sdk.Session{ID: "s_min", Title: "Bare"}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{}, svc, "s_min"))
|
||||
// Empty Description should not produce an empty `DESC:` line.
|
||||
for line := range strings.SplitSeq(out.String(), "\n") {
|
||||
if strings.HasPrefix(line, "DESC:") {
|
||||
t.Errorf("empty description should be omitted, found %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
)
|
||||
|
||||
// ConfirmPrompter is a test double for prompt.Prompter that scripts a single
|
||||
// Confirm answer (with optional error). Input/Password are stubbed to return
|
||||
// prompt.ErrAgentNoPrompt — assert it via `Asked` after the call.
|
||||
//
|
||||
// Use across cmd/* tests where a command's confirm-prompt branch needs to be
|
||||
// exercised. Avoid maintaining per-command copies of the same shape.
|
||||
type ConfirmPrompter struct {
|
||||
Answer bool
|
||||
Err error
|
||||
Asked bool
|
||||
}
|
||||
|
||||
func (c *ConfirmPrompter) Input(string, string) (string, error) {
|
||||
return "", prompt.ErrAgentNoPrompt
|
||||
}
|
||||
|
||||
func (c *ConfirmPrompter) Password(string) (string, error) {
|
||||
return "", prompt.ErrAgentNoPrompt
|
||||
}
|
||||
|
||||
func (c *ConfirmPrompter) Confirm(string, bool) (bool, error) {
|
||||
c.Asked = true
|
||||
return c.Answer, c.Err
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type KnowledgeBase struct {
|
||||
Name string `json:"name"` // Name must be unique within the same tenant
|
||||
Type string `json:"type"`
|
||||
IsTemporary bool `json:"is_temporary"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
Description string `json:"description"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
ChunkingConfig ChunkingConfig `json:"chunking_config"`
|
||||
|
||||
Reference in New Issue
Block a user