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:
nullkey
2026-05-12 23:34:33 +08:00
committed by lyingbug
parent 4c26bc9ecc
commit 2f8681b48e
21 changed files with 1338 additions and 30 deletions
+1 -1
View File
@@ -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
View File
@@ -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}
+9 -25
View File
@@ -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
+12
View File
@@ -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")
+99
View File
@@ -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
}
+108
View File
@@ -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 }
+98
View File
@@ -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
}
+93
View File
@@ -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
View File
@@ -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
}
+113
View File
@@ -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
}
+116
View File
@@ -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"`)
}
+2
View File
@@ -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
}
+96
View File
@@ -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
}
+94
View File
@@ -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")
}
+122
View File
@@ -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)
}
+140
View File
@@ -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")
}
+27
View File
@@ -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
}
+88
View File
@@ -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
}
+80
View File
@@ -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)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package testutil
import (
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// ConfirmPrompter is a test double for prompt.Prompter that scripts a single
// Confirm answer (with optional error). Input/Password are stubbed to return
// prompt.ErrAgentNoPrompt — assert it via `Asked` after the call.
//
// Use across cmd/* tests where a command's confirm-prompt branch needs to be
// exercised. Avoid maintaining per-command copies of the same shape.
type ConfirmPrompter struct {
Answer bool
Err error
Asked bool
}
func (c *ConfirmPrompter) Input(string, string) (string, error) {
return "", prompt.ErrAgentNoPrompt
}
func (c *ConfirmPrompter) Password(string) (string, error) {
return "", prompt.ErrAgentNoPrompt
}
func (c *ConfirmPrompter) Confirm(string, bool) (bool, error) {
c.Asked = true
return c.Answer, c.Err
}
+1
View File
@@ -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"`