feat(cli): doc delete multi-id + api --paginate + paginate fixes + batch deletes

Batch-write surface and pagination consistency:

* weknora doc delete <doc-id> [<doc-id>...] — positional multi-id, default
  keep-going on failure. Single -y confirms entire batch.
* weknora session delete <session-id> [<session-id>...] — same shape.
* weknora chunk delete <chunk-id> [<chunk-id>...] --doc <doc-id> — multi-id
  with shared --doc parent.
* Multi-id partial-failure rolls up as operation.failed (exit 1), not
  server.error (exit 7) — failures are operation outcomes, not transient
  transport issues, and the retry-with-backoff hint for server.* would
  mislead callers.
* weknora api <path> --paginate — auto-walks offset pagination and merges
  pages into a single {data, total} JSON response.
* Paginate truncation fix across 6 list/follower call sites.
* All doc / search / chunk / session / kb list commands migrated to
  FormatOptions API.

Multi-id RunE only emits the {ok, failed} envelope when the operation
actually ran — pre-flight failures (e.g. confirmation_required) leave
stdout empty per the wire contract.

doc upload's missing-positional-or-flag check is wrapped as FlagError so
the exit code (2) matches the convention used by other commands that
require a positional argument directly.
This commit is contained in:
nullkey
2026-05-18 01:38:30 +08:00
committed by lyingbug
parent 0e081aec5c
commit 34bb0b5096
33 changed files with 1259 additions and 406 deletions
+143 -13
View File
@@ -3,7 +3,7 @@
// Shape: one positional (path) + `-X/--method` flag, default GET (auto-
// promoted to POST when a body is supplied via --data or --input). The two
// body-source flags are mutually exclusive. Default raw response body to
// stdout; --json emits a {status, headers, body} object. Reuses sdk.Client.Raw which already
// stdout; --format json emits a {status, headers, body} object. Reuses sdk.Client.Raw which already
// applies tenant + auth headers.
package api
@@ -13,7 +13,9 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
@@ -25,8 +27,8 @@ import (
)
// apiFields is intentionally a marker - api wraps arbitrary HTTP responses
// whose schema the CLI doesn't know, so the `--json=id,name` field-filter
// is a no-op here. The marker shows up in --help so users can tell.
// whose schema the CLI doesn't know, so field hints are meaningless here.
// The marker shows up in --help so users can tell.
var apiFields = []string{"<response-shape-varies>"}
type Options struct {
@@ -58,7 +60,7 @@ POST. Use -X/--method to override (DELETE / PUT / PATCH / HEAD).
Auth, tenant, and request-id headers are applied automatically from the
active context. The response body is written to stdout by default; use
--json to emit a {status, headers, body} JSON object.
--format json to emit a {status, headers, body} JSON object.
Examples:
weknora api /api/v1/knowledge-bases # GET
@@ -67,15 +69,16 @@ Examples:
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Yes, _ = c.Flags().GetBool("yes")
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
method := resolveMethod(opts)
// Escape-hatch DELETE through `weknora api` is just as destructive
// as `weknora kb delete` - exit-10 protocol must apply (cli/README.md).
if method == http.MethodDelete {
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, jopts.Enabled(), "endpoint", args[0]); err != nil {
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "endpoint", args[0]); err != nil {
return err
}
}
@@ -83,13 +86,15 @@ Examples:
if err != nil {
return err
}
return runAPI(c.Context(), opts, jopts, cli, method, args[0])
paginate, _ := c.Flags().GetBool("paginate")
return runAPI(c.Context(), opts, fopts, cli, method, args[0], paginate)
},
}
cmd.Flags().StringVarP(&opts.Method, "method", "X", "", "HTTP method (default: GET, or POST when a body is supplied)")
cmd.Flags().StringVarP(&opts.Data, "data", "d", "", "Request body as raw string (e.g. JSON)")
cmd.Flags().StringVar(&opts.Input, "input", "", "Read request body from file (use `-` for stdin)")
cmdutil.AddJSONFlags(cmd, apiFields)
cmd.Flags().Bool("paginate", false, "Follow offset-based pagination (?page=N&page_size=M), merging all pages into a single {data, total} JSON response.")
cmdutil.AddFormatFlag(cmd, apiFields...)
cmd.MarkFlagsMutuallyExclusive("data", "input")
return cmd
}
@@ -133,7 +138,19 @@ func resolveMethod(opts *Options) string {
// caller is responsible for resolving the method (defaults / auto-POST)
// and uppercasing it; runAPI guards against unsupported values like
// `-X PATCH-INVALID` reaching the wire.
func runAPI(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc Service, method, path string) error {
//
// When paginate is true and method is GET, all offset-based pages are
// fetched and merged into a single {data, total} JSON response. For
// non-GET methods paginate is silently ignored (no offset semantic).
func runAPI(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc Service, method, path string, paginate bool) error {
if paginate && method == http.MethodGet {
return runAPIPaginated(ctx, opts, fopts, svc, path)
}
return runAPISingle(ctx, opts, fopts, svc, method, path)
}
// runAPISingle is the original single-call implementation of runAPI.
func runAPISingle(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc Service, method, path string) error {
switch method {
case http.MethodGet, http.MethodPost, http.MethodPut,
http.MethodPatch, http.MethodDelete, http.MethodHead:
@@ -177,7 +194,7 @@ func runAPI(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
}
out := iostreams.IO.Out
if jopts.Enabled() {
if fopts.WantsJSON() {
// Best-effort decode: if response body is valid JSON, surface the
// parsed structure under .body so JSON consumers can drill
// in; otherwise fall back to the raw string.
@@ -193,13 +210,14 @@ func runAPI(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
hdrs[k] = v[0]
}
}
// --json field-filter is ignored (response shape unknown to the
// CLI); --jq runs over the full {status, headers, body} object.
// --jq runs over the full {status, headers, body} object. Per-field
// projection isn't meaningful here since the response schema is opaque
// to the CLI.
return format.WriteJSONFiltered(out, map[string]any{
"status": resp.StatusCode,
"headers": hdrs,
"body": bodyAny,
}, nil, jopts.JQ)
}, nil, fopts.JQ)
}
if _, err := out.Write(respBody); err != nil {
@@ -211,5 +229,117 @@ func runAPI(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
return nil
}
// runAPIPaginated fetches all offset-based pages for a GET request and writes
// a single merged {data, total} JSON object to stdout. If the first page
// response does not carry pagination metadata (total + page_size), the raw
// response is passed through unchanged (single-call fallback).
func runAPIPaginated(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc Service, path string) error {
if !strings.HasPrefix(path, "/") {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("path must start with /: %s", path))
}
pageSize := extractPageSize(path)
if pageSize == 0 {
pageSize = 50
}
var allData []json.RawMessage
var lastTotal int64
page := 1
for {
curPath := setPageParam(path, page, pageSize)
resp, err := svc.Raw(ctx, http.MethodGet, curPath, nil)
if err != nil {
return cmdutil.WrapHTTP(err, "GET %s", curPath)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
code := cmdutil.ClassifyHTTPStatus(resp.StatusCode)
return cmdutil.NewError(code, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
}
var pageResp struct {
Data []json.RawMessage `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
if err := json.Unmarshal(body, &pageResp); err != nil {
// Non-JSON response on first page — pass through verbatim.
if page == 1 {
return passThroughRaw(body)
}
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("--paginate: page %d response not in expected shape: %v", page, err))
}
// Heuristic: if the first page lacks pagination metadata, treat the
// response as non-paginated and pass through verbatim.
if page == 1 && pageResp.Total == 0 && pageResp.PageSize == 0 {
return passThroughRaw(body)
}
allData = append(allData, pageResp.Data...)
lastTotal = pageResp.Total
// Termination: accumulated count (not page*pageSize) handles server-capped page sizes.
if int64(len(allData)) >= pageResp.Total || len(pageResp.Data) == 0 {
break
}
page++
}
merged := map[string]any{
"data": allData,
"total": lastTotal,
}
return fopts.Emit(iostreams.IO.Out, merged)
}
// passThroughRaw writes body verbatim to stdout (appending a newline if
// absent), mirroring the single-call passthrough path.
func passThroughRaw(body []byte) error {
out := iostreams.IO.Out
if _, err := out.Write(body); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "write response body")
}
if len(body) > 0 && body[len(body)-1] != '\n' {
_, _ = out.Write([]byte{'\n'})
}
return nil
}
// extractPageSize parses the page_size query parameter from path, returning 0
// if absent or unparseable.
func extractPageSize(path string) int {
u, err := url.Parse(path)
if err != nil {
return 0
}
if v := u.Query().Get("page_size"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return 0
}
// setPageParam rewrites the page and page_size query parameters in path,
// preserving all other query parameters.
func setPageParam(path string, page, pageSize int) string {
u, err := url.Parse(path)
if err != nil {
return path
}
q := u.Query()
q.Set("page", strconv.Itoa(page))
q.Set("page_size", strconv.Itoa(pageSize))
u.RawQuery = q.Encode()
return u.String()
}
// compile-time check: the production SDK client implements Service.
var _ Service = (*sdk.Client)(nil)
+156 -8
View File
@@ -1,8 +1,10 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -19,6 +21,16 @@ import (
sdk "github.com/Tencent/WeKnora/client"
)
// fakeAPISvc is a test double for Service that delegates each call to a
// caller-supplied do function, giving full control over per-call responses.
type fakeAPISvc struct {
do func(method, path string, body any) (*http.Response, error)
}
func (f *fakeAPISvc) Raw(_ context.Context, method, path string, body any) (*http.Response, error) {
return f.do(method, path, body)
}
// newTestClient stands up an httptest server with the supplied handler and
// returns an *sdk.Client targeting it plus a teardown closure. The real SDK is
// used so we exercise the same Raw() code path as production (header
@@ -40,7 +52,7 @@ func TestAPI_GetSuccess(t *testing.T) {
})
defer stop()
if err := runAPI(context.Background(), &Options{}, nil, cli, "GET", "/api/v1/foo"); err != nil {
if err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "GET", "/api/v1/foo", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
got := out.String()
@@ -62,7 +74,7 @@ func TestAPI_GetSuccess_JSON(t *testing.T) {
})
defer stop()
if err := runAPI(context.Background(), &Options{}, &cmdutil.JSONOptions{}, cli, "GET", "/api/v1/foo"); err != nil {
if err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, cli, "GET", "/api/v1/foo", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
var got struct {
@@ -98,7 +110,7 @@ func TestAPI_PostWithData(t *testing.T) {
defer stop()
opts := &Options{Data: `{"name":"foo"}`}
if err := runAPI(context.Background(), opts, nil, cli, "POST", "/api/v1/things"); err != nil {
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "POST", "/api/v1/things", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
if seenMethod != http.MethodPost || seenPath != "/api/v1/things" {
@@ -127,7 +139,7 @@ func TestAPI_InputFile(t *testing.T) {
defer stop()
opts := &Options{Input: tmp}
if err := runAPI(context.Background(), opts, nil, cli, "POST", "/api/v1/x"); err != nil {
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "POST", "/api/v1/x", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
if string(seenBody) != payload {
@@ -148,7 +160,7 @@ func TestAPI_InputDash_Stdin(t *testing.T) {
payload := `{"k":"from-stdin"}`
opts := &Options{Input: "-", StdinReader: strings.NewReader(payload)}
if err := runAPI(context.Background(), opts, nil, cli, "POST", "/api/v1/x"); err != nil {
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "POST", "/api/v1/x", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
if string(seenBody) != payload {
@@ -164,7 +176,7 @@ func TestAPI_NotFound(t *testing.T) {
})
defer stop()
err := runAPI(context.Background(), &Options{}, nil, cli, "GET", "/api/v1/missing")
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "GET", "/api/v1/missing", false)
if err == nil {
t.Fatal("expected error for 404")
}
@@ -176,7 +188,7 @@ func TestAPI_NotFound(t *testing.T) {
func TestAPI_InvalidMethod(t *testing.T) {
_, _ = iostreams.SetForTest(t)
// No server needed: validation should fail before dispatch.
err := runAPI(context.Background(), &Options{}, nil, nil, "FOO", "/api/v1/things")
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, nil, "FOO", "/api/v1/things", false)
if err == nil {
t.Fatal("expected error for unsupported method")
}
@@ -188,7 +200,7 @@ func TestAPI_InvalidMethod(t *testing.T) {
func TestAPI_PathWithoutSlash(t *testing.T) {
_, _ = iostreams.SetForTest(t)
err := runAPI(context.Background(), &Options{}, nil, nil, "GET", "api/v1/things")
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, nil, "GET", "api/v1/things", false)
if err == nil {
t.Fatal("expected error for missing leading slash")
}
@@ -281,3 +293,139 @@ func asTypedError(err error, dst **cmdutil.Error) bool {
}
return false
}
func TestAPI_PaginateMergesPages(t *testing.T) {
pages := [][]byte{
[]byte(`{"success":true,"data":[{"id":"1"},{"id":"2"}],"total":5,"page":1,"page_size":2}`),
[]byte(`{"success":true,"data":[{"id":"3"},{"id":"4"}],"total":5,"page":2,"page_size":2}`),
[]byte(`{"success":true,"data":[{"id":"5"}],"total":5,"page":3,"page_size":2}`),
}
idx := 0
svc := &fakeAPISvc{do: func(method, path string, _ any) (*http.Response, error) {
if idx >= len(pages) {
return nil, fmt.Errorf("too many calls; idx=%d", idx)
}
body := pages[idx]
idx++
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader(body)),
Header: make(http.Header),
}, nil
}}
out, _ := iostreams.SetForTest(t)
opts := &Options{}
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "GET", "/api/v1/knowledge-base?page=1&page_size=2", true); err != nil {
t.Fatalf("runAPI: %v", err)
}
var got struct {
Data []map[string]string `json:"data"`
Total int `json:"total"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, out.String())
}
if len(got.Data) != 5 || got.Total != 5 {
t.Errorf("got %d records (total %d), want 5/5", len(got.Data), got.Total)
}
if idx != 3 {
t.Errorf("called %d times, want 3", idx)
}
}
func TestAPI_PaginateIgnoredForPOST(t *testing.T) {
// --paginate should be a no-op for non-GET methods (no pagination
// semantic for POST/PUT/DELETE). Single call expected.
called := 0
svc := &fakeAPISvc{do: func(method, path string, _ any) (*http.Response, error) {
called++
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader([]byte(`{"success":true,"data":[],"total":5,"page":1,"page_size":2}`))),
Header: make(http.Header),
}, nil
}}
_, _ = iostreams.SetForTest(t)
opts := &Options{Data: `{"name":"foo"}`}
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "POST", "/api/v1/knowledge-base", true); err != nil {
t.Fatalf("runAPI: %v", err)
}
if called != 1 {
t.Errorf("called %d times, want 1 (POST should not paginate)", called)
}
}
func TestAPI_PaginateNoMetadataPassesThrough(t *testing.T) {
// If response doesn't look paginated (no total/page/page_size), --paginate
// should fall back to single-call behavior (don't crash).
called := 0
svc := &fakeAPISvc{do: func(method, path string, _ any) (*http.Response, error) {
called++
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader([]byte(`{"hello":"world"}`))),
Header: make(http.Header),
}, nil
}}
_, _ = iostreams.SetForTest(t)
opts := &Options{}
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "GET", "/api/v1/whoami", true); err != nil {
t.Fatalf("runAPI: %v", err)
}
if called != 1 {
t.Errorf("called %d times, want 1 (non-paginated response)", called)
}
}
// TestAPI_PaginateServerCapsPageSize covers the case where the user
// requests --page_size=50 but the server caps page_size at a smaller
// value (e.g. 2). Termination must count actually-collected records
// (len(allData)) not requested-page-count (page*pageSize) — otherwise
// we'd break early and silently truncate results.
func TestAPI_PaginateServerCapsPageSize(t *testing.T) {
// User asks page_size=10; server only ever returns 2 per page (cap).
// Total = 5 records; should make 3 calls (2+2+1) and return all 5.
pages := [][]byte{
[]byte(`{"success":true,"data":[{"id":"1"},{"id":"2"}],"total":5,"page":1,"page_size":2}`),
[]byte(`{"success":true,"data":[{"id":"3"},{"id":"4"}],"total":5,"page":2,"page_size":2}`),
[]byte(`{"success":true,"data":[{"id":"5"}],"total":5,"page":3,"page_size":2}`),
}
idx := 0
svc := &fakeAPISvc{do: func(_, _ string, _ any) (*http.Response, error) {
if idx >= len(pages) {
return nil, fmt.Errorf("too many calls; idx=%d", idx)
}
body := pages[idx]
idx++
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader(body)),
Header: make(http.Header),
}, nil
}}
var stdout bytes.Buffer
iostreams.IO.Out = &stdout
defer func() { iostreams.IO.Out = os.Stdout }()
opts := &Options{}
// User requests page_size=10; server caps at 2 each response.
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "GET", "/api/v1/items?page=1&page_size=10", true); err != nil {
t.Fatalf("runAPI: %v", err)
}
var got struct {
Data []map[string]string `json:"data"`
Total int `json:"total"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, stdout.String())
}
if len(got.Data) != 5 {
t.Errorf("got %d records, want 5 (server-capped page_size should not cause truncation)", len(got.Data))
}
}
+103 -34
View File
@@ -3,6 +3,7 @@ package chunkcmd
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
@@ -12,13 +13,12 @@ import (
)
// chunkDeleteFields enumerates the JSON discovery fields for `chunk delete`.
// Result payload is a tiny {id, deleted} object — mirrors `kb delete` /
// `agent delete`.
// Tracks the single-id result struct; multi-id mode emits MultiDeleteResult.
var chunkDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {
ChunkID string
DocID string // required: SDK DeleteChunk takes both ids in the route.
ChunkID string // single-id path
DocID string // required: SDK DeleteChunk takes both ids in the route
Yes bool // sourced from the global -y/--yes persistent flag
}
@@ -27,27 +27,44 @@ type DeleteService interface {
DeleteChunk(ctx context.Context, docID, chunkID string) error
}
// deleteResult is the typed payload emitted on success in JSON mode.
// deleteResult is the typed payload emitted on single-id success in JSON mode.
type deleteResult struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
}
// Delete is NOT idempotent on a missing id — it surfaces resource.not_found
// (exit 4). Idempotent-already-true semantics are reserved for `unlink`-style
// local cleanups, not server-side resource removal. Mirrors `agent delete`
// and `kb delete`.
const chunkDeleteLong = `Permanently delete a chunk from a document.
// MultiDeleteResult is the payload for multi-id deletes. All chunks share the
// same --doc parent (server route is DELETE /chunks/{doc}/{id}).
type MultiDeleteResult struct {
OK []string `json:"ok"`
Failed []FailedItem `json:"failed,omitempty"`
}
Requires both the chunk id (positional) and the parent document id
(--doc) because the server route encodes both: DELETE /chunks/{doc}/{id}.
The CLI does not auto-resolve doc id from the chunk id because doing so
would add a round-trip and open a race with the ingest pipeline (a chunk
could move between documents between resolve and delete).
// FailedItem records an id that failed to delete along with its error message.
type FailedItem struct {
ID string `json:"id"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
}
Prompts for confirmation by default when stdout is a TTY and --json is
not set. Pass -y/--yes (the global flag) to skip the prompt (required in
agent / CI / piped contexts).
const chunkDeleteLong = `Permanently delete one or more chunks from a document.
Requires both the chunk id(s) (positional, repeatable) and the parent
document id (--doc) because the server route encodes both:
DELETE /chunks/{doc}/{id}. All chunks in a multi-id call must share the
same --doc. The CLI does not auto-resolve doc id from chunk id because
that would add a round-trip and open a race with the ingest pipeline
(a chunk could move between documents between resolve and delete).
Single-id: one confirm prompt, exit 0/1.
Multi-id:
• Default keep-going: failed deletes do NOT stop the run; failures collected.
• One -y/--yes confirms all chunks.
• Exit 0 if all succeed; exit 1 if any failed.
Prompts for confirmation by default when stdout is a TTY and JSON output
is not set. Pass -y/--yes (the global flag) to skip the prompt (required
in agent / CI / piped contexts).
Typed exit codes:
resource.not_found no chunk with the given id under that doc (exit 4)
@@ -59,49 +76,101 @@ and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead — the exit-10 protocol exists
exactly to guard against unintended deletes.`
const chunkDeleteExample = ` weknora chunk delete chunk_abc --doc doc_xyz # interactive confirm
weknora chunk delete chunk_abc --doc doc_xyz -y # no prompt
weknora chunk delete chunk_abc --doc doc_xyz -y --json # bare {id, deleted:true} JSON`
const chunkDeleteExample = ` weknora chunk delete chunk_abc --doc doc_xyz # interactive confirm
weknora chunk delete chunk_abc --doc doc_xyz -y # no prompt
weknora chunk delete chunk_abc --doc doc_xyz -y --format json # bare {id, deleted:true} JSON
weknora chunk delete c1 c2 c3 --doc doc_xyz -y # delete 3 chunks under same doc, keep-going`
// NewCmdDelete builds `weknora chunk delete <chunk-id> --doc <doc-id>`.
// NewCmdDelete builds `weknora chunk delete <chunk-id> [<chunk-id>...] --doc <doc-id>`.
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
opts := &DeleteOptions{}
cmd := &cobra.Command{
Use: "delete <chunk-id> --doc <doc-id>",
Short: "Delete a chunk from a document (scoped)",
Use: "delete <chunk-id> [<chunk-id>...] --doc <doc-id>",
Short: "Delete one or more chunks from a document (scoped)",
Long: chunkDeleteLong,
Example: chunkDeleteExample,
Args: cobra.ExactArgs(1),
Args: cobra.MinimumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
opts.ChunkID = args[0]
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Yes, _ = c.Flags().GetBool("yes")
cli, err := f.Client()
if err != nil {
return err
}
return runDelete(c.Context(), opts, jopts, cli, f.Prompter())
if len(args) == 1 {
opts.ChunkID = args[0]
return runDelete(c.Context(), opts, fopts, cli, f.Prompter())
}
res, runErr := runMultiDelete(c.Context(), opts, fopts, cli, f.Prompter(), args)
// Only emit when the operation actually ran. Pre-flight errors
// (e.g. confirmation_required) must leave stdout empty per the
// wire contract in README.md.
if len(res.OK) > 0 || len(res.Failed) > 0 {
if emitErr := emitMultiDelete(res, fopts, iostreams.IO.Out); emitErr != nil {
return emitErr
}
}
return runErr
},
}
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Parent document id (SDK knowledge_id) the chunk lives under")
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Parent document id (SDK knowledge_id) the chunks live under")
_ = cmd.MarkFlagRequired("doc")
cmdutil.AddJSONFlags(cmd, chunkDeleteFields)
cmdutil.AddFormatFlag(cmd, chunkDeleteFields...)
return cmd
}
func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOptions, svc DeleteService, p prompt.Prompter) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "chunk", opts.ChunkID); err != nil {
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "chunk", opts.ChunkID); err != nil {
return err
}
if err := svc.DeleteChunk(ctx, opts.DocID, opts.ChunkID); err != nil {
return cmdutil.WrapHTTP(err, "delete chunk %s", opts.ChunkID)
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.ChunkID, Deleted: true})
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.ChunkID, Deleted: true})
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted chunk %s\n", opts.ChunkID)
return nil
}
// runMultiDelete iterates chunkIDs sequentially under opts.DocID, keep-going
// on error: a single failure does not abort the run.
func runMultiDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter, chunkIDs []string) (*MultiDeleteResult, error) {
if err := cmdutil.ConfirmDestructiveBatch(p, opts.Yes, fopts.WantsJSON(), "chunk", len(chunkIDs)); err != nil {
return &MultiDeleteResult{}, err
}
res := &MultiDeleteResult{}
for _, id := range chunkIDs {
if err := svc.DeleteChunk(ctx, opts.DocID, id); err != nil {
res.Failed = append(res.Failed, FailedItem{ID: id, Message: err.Error()})
continue
}
res.OK = append(res.OK, id)
}
if len(res.Failed) > 0 {
return res, cmdutil.NewError(cmdutil.CodeOperationFailed, fmt.Sprintf("%d/%d delete(s) failed", len(res.Failed), len(chunkIDs)))
}
return res, nil
}
// emitMultiDelete renders per --format. Mirrors doc / session delete.
func emitMultiDelete(res *MultiDeleteResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
switch fopts.Mode {
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
return fopts.Emit(w, res)
case cmdutil.FormatText, "":
for _, id := range res.OK {
fmt.Fprintf(w, "OK %s\n", id)
}
for _, f := range res.Failed {
fmt.Fprintf(w, "FAIL %s: %s\n", f.ID, f.Message)
}
return nil
default:
return fmt.Errorf("unsupported --format %q for chunk delete", fopts.Mode)
}
}
+72 -6
View File
@@ -29,7 +29,7 @@ func TestDelete_NonTTY_NoYes_ExitTen(t *testing.T) {
svc := &fakeChunkDeleteSvc{}
err := runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: false},
&cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{})
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -43,7 +43,7 @@ func TestDelete_WithYes_PassesBothIDs(t *testing.T) {
svc := &fakeChunkDeleteSvc{}
require.NoError(t, runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: true},
&cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{}))
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{}))
assert.Equal(t, "doc_abc", svc.gotDocID)
assert.Equal(t, "c1", svc.gotChunkID)
}
@@ -61,7 +61,7 @@ func TestDelete_404_PropagatesNotFound(t *testing.T) {
svc := &fakeChunkDeleteSvc{err: errors.New("HTTP error 404: not found")}
err := runDelete(context.Background(),
&DeleteOptions{ChunkID: "missing", DocID: "doc_abc", Yes: true},
&cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{})
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -74,7 +74,7 @@ func TestDelete_TTY_ConfirmYes_Calls(t *testing.T) {
p := &testutil.ConfirmPrompter{Answer: true}
require.NoError(t, runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc"},
nil, svc, p))
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p))
assert.True(t, p.Asked)
assert.Equal(t, "c1", svc.gotChunkID)
}
@@ -85,7 +85,7 @@ func TestDelete_TTY_ConfirmNo_Aborts(t *testing.T) {
p := &testutil.ConfirmPrompter{Answer: false}
err := runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc"},
nil, svc, p)
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -99,8 +99,74 @@ func TestDelete_JSON_BareObject(t *testing.T) {
svc := &fakeChunkDeleteSvc{}
require.NoError(t, runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: true},
&cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{}))
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{}))
body := out.String()
assert.Contains(t, body, `"id":"c1"`)
assert.Contains(t, body, `"deleted":true`)
}
// ---------------------------------------------------------------------------
// Multi-id (all chunks share --doc, keep-going on failure)
// ---------------------------------------------------------------------------
// fakeMultiChunkDeleteSvc records (docID, chunkID) pairs and can fail-on
// selected chunkIDs.
type fakeMultiChunkDeleteSvc struct {
deleted []string // chunk ids successfully deleted
docIDs []string // doc id observed for each call
failOn map[string]error
}
func (f *fakeMultiChunkDeleteSvc) DeleteChunk(_ context.Context, docID, chunkID string) error {
f.docIDs = append(f.docIDs, docID)
if e, ok := f.failOn[chunkID]; ok {
return e
}
f.deleted = append(f.deleted, chunkID)
return nil
}
func TestMultiDelete_AllSucceed_SharedDoc(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeMultiChunkDeleteSvc{}
res, err := runMultiDelete(context.Background(),
&DeleteOptions{DocID: "doc_xyz", Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
[]string{"c1", "c2", "c3"})
require.NoError(t, err)
assert.Equal(t, []string{"c1", "c2", "c3"}, res.OK)
assert.Empty(t, res.Failed)
// All calls observed the same --doc.
for _, d := range svc.docIDs {
assert.Equal(t, "doc_xyz", d)
}
}
func TestMultiDelete_PartialFailure_KeepsGoing(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeMultiChunkDeleteSvc{failOn: map[string]error{"c2": errors.New("HTTP error 404: not found")}}
res, err := runMultiDelete(context.Background(),
&DeleteOptions{DocID: "doc_xyz", Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
[]string{"c1", "c2", "c3"})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
assert.Equal(t, []string{"c1", "c3"}, res.OK, "keep-going: c3 still attempted after c2 failed")
assert.Len(t, res.Failed, 1)
assert.Equal(t, "c2", res.Failed[0].ID)
}
func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeMultiChunkDeleteSvc{}
res, err := runMultiDelete(context.Background(),
&DeleteOptions{DocID: "doc_xyz"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
[]string{"c1", "c2"})
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.Empty(t, res.OK)
assert.Empty(t, svc.deleted)
}
+11 -10
View File
@@ -22,7 +22,7 @@ const (
previewWidth = 80
)
// chunkListFields enumerates the fields surfaced for `--json` discovery on
// chunkListFields enumerates the fields surfaced for `--format json` discovery on
// `chunk list`. Mirrors sdk.Chunk json tags — all 23 fields are projectable
// because chunk list returns bare SDK objects.
var chunkListFields = []string{
@@ -48,7 +48,7 @@ type ListOptions struct {
// Default 50 chosen as domain-tuned for chunk enumeration (RAG debug).
Limit int
// AllPages walks server pages internally until total exhausted or
// --limit hit. Mirrors session list / doc list canon.
// --limit hit. Mirrors the session list / doc list pagination pattern.
AllPages bool
}
@@ -73,7 +73,7 @@ specific document.`
const chunkListExample = ` weknora chunk list --doc doc_abc
weknora chunk list --doc doc_abc --all-pages --page-size 100
weknora chunk list --doc doc_abc --json | jq '.[] | {id, chunk_index}'`
weknora chunk list --doc doc_abc --format json | jq '.[] | {id, chunk_index}'`
// NewCmdList builds `weknora chunk list --doc <doc-id>`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
@@ -85,15 +85,16 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
Example: chunkListExample,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runList(c.Context(), opts, jopts, cli)
return runList(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Document id (SDK knowledge_id) to enumerate chunks for")
@@ -101,11 +102,11 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum results to return (1..1000)")
cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per server batch (1..1000)")
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")
cmdutil.AddJSONFlags(cmd, chunkListFields)
cmdutil.AddFormatFlag(cmd, chunkListFields...)
return cmd
}
func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, svc ListService) error {
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
if opts.Limit < 1 || opts.Limit > maxLimit {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
@@ -132,7 +133,7 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
accum = accum[:opts.Limit]
break
}
if len(chunks) == 0 || int64(page*opts.PageSize) >= total {
if len(chunks) == 0 || int64(len(accum)) >= total {
break
}
}
@@ -151,8 +152,8 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
items = items[:opts.Limit]
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, items)
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, items)
}
if len(items) == 0 {
+10 -10
View File
@@ -47,7 +47,7 @@ func TestList_Happy(t *testing.T) {
totals: []int64{2}, errs: []error{nil},
}
opts := &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
require.Len(t, svc.calls, 1)
assert.Equal(t, "doc_abc", svc.calls[0].docID)
assert.Equal(t, 1, svc.calls[0].page)
@@ -75,7 +75,7 @@ func TestList_AllPages_StopsOnEmptyPage(t *testing.T) {
errs: []error{nil, nil, nil},
}
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 1000}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, 3, len(svc.calls), "must stop on empty page, not loop forever")
}
@@ -93,7 +93,7 @@ func TestList_AllPages_StopsOnTotalExhausted(t *testing.T) {
errs: []error{nil, nil},
}
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 1000}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
// After page 2: page*pageSize=4 >= total=3 → stop. No 3rd request.
assert.Equal(t, 2, len(svc.calls), "must stop when total exhausted, no extra empty probe")
}
@@ -113,7 +113,7 @@ func TestList_AllPages_LimitTruncatesAccumulated(t *testing.T) {
errs: []error{nil, nil, nil},
}
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 3}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
// After page 2: accum=4 >= limit=3 → stop. Third page never requested.
assert.LessOrEqual(t, len(svc.calls), 2, "must not walk past limit-hit point")
// Result must be exactly --limit items (server returned 4, sliced to 3).
@@ -127,7 +127,7 @@ func TestList_AllPages_LimitTruncatesAccumulated(t *testing.T) {
func TestList_LimitInvalid(t *testing.T) {
svc := &fakeListSvc{}
for _, lim := range []int{0, -1, 1001} {
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: lim, PageSize: 50}, &cmdutil.JSONOptions{}, svc)
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: lim, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
require.Error(t, err, "expect error for --limit %d", lim)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
@@ -136,7 +136,7 @@ func TestList_LimitInvalid(t *testing.T) {
func TestList_PageSizeInvalid(t *testing.T) {
svc := &fakeListSvc{}
for _, ps := range []int{0, -1, 1001} {
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: 50, PageSize: ps}, &cmdutil.JSONOptions{}, svc)
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
@@ -158,7 +158,7 @@ func TestList_Human_TableHeader(t *testing.T) {
}},
totals: []int64{1}, errs: []error{nil},
}
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, nil, svc))
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
for _, want := range []string{"CHUNK_ID", "INDEX", "TYPE", "ENABLED", "PREVIEW", "UPDATED", "c1", "text"} {
assert.Contains(t, body, want)
@@ -172,7 +172,7 @@ func TestList_Human_PreviewTruncatedTo80(t *testing.T) {
pages: [][]sdk.Chunk{{{ID: "c1", Content: long, ChunkType: "text", IsEnabled: true}}},
totals: []int64{1}, errs: []error{nil},
}
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, nil, svc))
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
// 80-col preview means we never see the 100th `a` from the content (only column truncation kicks in).
assert.NotContains(t, body, strings.Repeat("a", 100), "preview must be truncated to ~80 chars")
@@ -186,7 +186,7 @@ func TestList_JSON_BareArray(t *testing.T) {
}},
totals: []int64{1}, errs: []error{nil},
}
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
var got []sdk.Chunk
require.NoError(t, json.Unmarshal(out.Bytes(), &got))
require.Len(t, got, 1)
@@ -200,6 +200,6 @@ func TestList_JSON_BareArray(t *testing.T) {
func TestList_EmptyResultRendersBareArray(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{pages: [][]sdk.Chunk{{}}, totals: []int64{0}, errs: []error{nil}}
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, "[]\n", out.String())
}
+12 -11
View File
@@ -12,7 +12,7 @@ import (
sdk "github.com/Tencent/WeKnora/client"
)
// chunkViewFields enumerates the 23 SDK Chunk fields surfaced for `--json`
// chunkViewFields enumerates the 23 SDK Chunk fields surfaced for `--format json`
// discovery. JSON is bare SDK pass-through, so keys are snake_case
// (`knowledge_id`, `knowledge_base_id`) even though the human KV labels
// them as `doc_id` / `kb_id`.
@@ -35,9 +35,9 @@ type ViewOptions struct {
const chunkViewLong = `Show a single chunk with all SDK fields.
Human output is a key-value block; pass --json for the bare 23-field SDK
Human output is a key-value block; pass --format json for the bare 23-field SDK
Chunk object. Content renders verbatim regardless of size — pipe to
less or use --json for large chunks. WeKnora chunks are typically bounded
less or use --format json for large chunks. WeKnora chunks are typically bounded
by the ingest pipeline (~1000 tokens / a few KB), so unconditional full
rendering is reasonable.
@@ -54,8 +54,8 @@ Typed exit codes:
resource.not_found no chunk with the given id (exit 4)`
const chunkViewExample = ` weknora chunk view chunk_abc
weknora chunk view chunk_abc --json | jq '.content'
weknora chunk view chunk_abc --json=id,chunk_index,is_enabled`
weknora chunk view chunk_abc --format json | jq '.content'
weknora chunk view chunk_abc --format json --jq '{id, chunk_index, is_enabled}'`
// NewCmdView builds `weknora chunk view <chunk-id>`.
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
@@ -67,29 +67,30 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
Example: chunkViewExample,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.ChunkID = args[0]
cli, err := f.Client()
if err != nil {
return err
}
return runView(c.Context(), opts, jopts, cli)
return runView(c.Context(), opts, fopts, cli)
},
}
cmdutil.AddJSONFlags(cmd, chunkViewFields)
cmdutil.AddFormatFlag(cmd, chunkViewFields...)
return cmd
}
func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions, svc ViewService) error {
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService) error {
ch, err := svc.GetChunkByIDOnly(ctx, opts.ChunkID)
if err != nil {
return cmdutil.WrapHTTP(err, "fetch chunk %s", opts.ChunkID)
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, ch)
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, ch)
}
renderChunk(iostreams.IO.Out, ch)
return nil
+6 -6
View File
@@ -37,7 +37,7 @@ func TestView_Happy_RendersAllFields(t *testing.T) {
CreatedAt: "2026-05-15T11:00:00Z",
UpdatedAt: "2026-05-15T12:00:00Z",
}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, nil, svc))
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
assert.Contains(t, body, "c1")
assert.Contains(t, body, "doc_abc")
@@ -51,10 +51,10 @@ func TestView_HumanLabels_DocAndKB(t *testing.T) {
svc := &fakeViewSvc{resp: &sdk.Chunk{
ID: "c1", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_abc",
}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, nil, svc))
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
// Human KV uses friendlier DOC_ID / KB_ID labels (the SDK's
// knowledge_id / knowledge_base_id are kept only in --json output).
// knowledge_id / knowledge_base_id are kept only in --format json output).
assert.Contains(t, body, "doc_id")
assert.Contains(t, body, "kb_id")
assert.NotContains(t, body, "knowledge_id")
@@ -64,7 +64,7 @@ func TestView_HumanLabels_DocAndKB(t *testing.T) {
func TestView_OmitsZeroOrEmpty(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Chunk{ID: "c_min", Content: "x"}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_min"}, nil, svc))
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_min"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
// status / start_at / end_at all zero → must be omitted from the human KV.
assert.NotContains(t, body, "status:")
@@ -80,7 +80,7 @@ func TestView_JSON_BareSDKShape(t *testing.T) {
svc := &fakeViewSvc{resp: &sdk.Chunk{
ID: "c_json", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_abc",
}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_json"}, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_json"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
var got sdk.Chunk
require.NoError(t, json.Unmarshal(out.Bytes(), &got))
assert.Equal(t, "c_json", got.ID)
@@ -95,7 +95,7 @@ func TestView_JSON_BareSDKShape(t *testing.T) {
func TestView_404(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeViewSvc{err: errors.New("HTTP error 404: not found")}
err := runView(context.Background(), &ViewOptions{ChunkID: "missing"}, nil, svc)
err := runView(context.Background(), &ViewOptions{ChunkID: "missing"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "resource.not_found")
}
+98 -21
View File
@@ -3,6 +3,7 @@ package doc
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
@@ -11,8 +12,8 @@ import (
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// docDeleteFields enumerates the fields surfaced for `--json` discovery on
// `doc delete`. The result payload is a small {id, deleted} object.
// docDeleteFields enumerates the fields surfaced for `--format json` discovery
// on `doc delete`. The result payload is a small {id, deleted} object.
var docDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {
@@ -25,49 +26,87 @@ type DeleteService interface {
DeleteKnowledge(ctx context.Context, id string) error
}
// deleteResult is the typed payload emitted under data on success.
// deleteResult is the typed payload emitted under data on success (single-id).
type deleteResult struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
}
// NewCmdDelete builds `weknora doc delete`. Confirmation routed through
// the global -y/--yes persistent flag.
// MultiDeleteResult is the payload for multi-id deletes.
// ok: ids successfully deleted; failed: ids that could not be deleted.
type MultiDeleteResult struct {
OK []string `json:"ok"`
Failed []FailedItem `json:"failed,omitempty"`
}
// FailedItem records an id that failed to delete along with its error message.
type FailedItem struct {
ID string `json:"id"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
}
// NewCmdDelete builds `weknora doc delete`. Single-id keeps the simpler
// code path (one confirm prompt, exit 0/1); multi-id uses keep-going
// semantics (one -y confirms all, failures collected, exit 1 if any fail).
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
opts := &DeleteOptions{}
cmd := &cobra.Command{
Use: "delete <id>",
Short: "Delete a document from a knowledge base",
Long: `Permanently deletes one document. 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).
Use: "delete <doc-id> [<doc-id>...]",
Short: "Delete one or more documents from a knowledge base",
Long: `Permanently deletes one or more documents. Prompts for confirmation by
default when stdout is a TTY and JSON output is not set; pass -y/--yes
(global flag) to skip the prompt (required in agent / CI / piped contexts).
Single-id: one confirm prompt, exit 0/1.
Multi-id:
• Default keep-going: failed deletes do NOT stop the run; failures collected.
• One -y/--yes confirms all documents.
• TTY prompt shows total: "Delete N document(s)? This cannot be undone."
• Exit 0 if all succeed; exit 1 if any failed.
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead.`,
Example: ` weknora doc delete doc_abc # interactive confirm
weknora doc delete doc_abc -y # no prompt
weknora doc delete doc_abc -y --json # bare {id, deleted:true} JSON`,
Args: cobra.ExactArgs(1),
Example: ` weknora doc delete doc_abc # interactive confirm
weknora doc delete doc_abc -y # no prompt
weknora doc delete doc_abc -y --format json # bare {id, deleted:true} JSON
weknora doc delete doc_a doc_b doc_c -y # delete 3, keep-going
weknora doc delete doc_a doc_b --format json # multi-id JSON output`,
Args: cobra.MinimumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Yes, _ = c.Flags().GetBool("yes")
cli, err := f.Client()
if err != nil {
return err
}
return runDelete(c.Context(), opts, jopts, cli, f.Prompter(), args[0])
// Single-id uses the simpler code path (bare {id, deleted}).
if len(args) == 1 {
return runDelete(c.Context(), opts, fopts, cli, f.Prompter(), args[0])
}
res, runErr := runMultiDelete(c.Context(), opts, fopts, cli, f.Prompter(), args)
// Only emit when the operation actually ran. Pre-flight errors
// (e.g. confirmation_required) must leave stdout empty per the
// wire contract in README.md.
if len(res.OK) > 0 || len(res.Failed) > 0 {
if emitErr := emitMultiDelete(res, fopts, iostreams.IO.Out); emitErr != nil {
return emitErr
}
}
return runErr
},
}
cmdutil.AddJSONFlags(cmd, docDeleteFields)
cmdutil.AddFormatFlag(cmd, docDeleteFields...)
return cmd
}
func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOptions, svc DeleteService, p prompt.Prompter, id string) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "document", id); err != nil {
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter, id string) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "document", id); err != nil {
return err
}
@@ -75,9 +114,47 @@ func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOpti
return cmdutil.WrapHTTP(err, "delete document %s", id)
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true})
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true})
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted document %s\n", id)
return nil
}
// runMultiDelete iterates ids sequentially, keep-going on error: a single
// failure does not abort the run, so the caller sees the full outcome.
func runMultiDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter, ids []string) (*MultiDeleteResult, error) {
if err := cmdutil.ConfirmDestructiveBatch(p, opts.Yes, fopts.WantsJSON(), "document", len(ids)); err != nil {
return &MultiDeleteResult{}, err
}
res := &MultiDeleteResult{}
for _, id := range ids {
if err := svc.DeleteKnowledge(ctx, id); err != nil {
res.Failed = append(res.Failed, FailedItem{ID: id, Message: err.Error()})
continue
}
res.OK = append(res.OK, id)
}
if len(res.Failed) > 0 {
return res, cmdutil.NewError(cmdutil.CodeOperationFailed, fmt.Sprintf("%d/%d delete(s) failed", len(res.Failed), len(ids)))
}
return res, nil
}
// emitMultiDelete renders per --format. Mirrors emitWaitResult / emitStatus.
func emitMultiDelete(res *MultiDeleteResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
switch fopts.Mode {
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
return fopts.Emit(w, res)
case cmdutil.FormatText, "":
for _, id := range res.OK {
fmt.Fprintf(w, "OK %s\n", id)
}
for _, f := range res.Failed {
fmt.Fprintf(w, "FAIL %s: %s\n", f.ID, f.Message)
}
return nil
default:
return fmt.Errorf("unsupported --format %q for doc delete", fopts.Mode)
}
}
+190 -13
View File
@@ -1,7 +1,9 @@
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -14,26 +16,45 @@ import (
"github.com/Tencent/WeKnora/cli/internal/testutil"
)
// fakeDeleteSvc captures the id passed and returns a canned error.
// fakeDeleteSvc captures calls and returns canned errors.
// errFor maps id → error for per-id failure injection (used in multi-id tests).
type fakeDeleteSvc struct {
err error
got string
calls int
err error
errFor map[string]error
got string
calls int
// deleted tracks all successfully deleted ids (multi-id tests).
deleted []string
}
func (f *fakeDeleteSvc) DeleteKnowledge(_ context.Context, id string) error {
f.calls++
f.got = id
return f.err
if f.errFor != nil {
if err, ok := f.errFor[id]; ok {
return err
}
f.deleted = append(f.deleted, id)
return nil
}
if f.err != nil {
return f.err
}
f.deleted = append(f.deleted, id)
return nil
}
// ---------------------------------------------------------------------------
// Single-id tests — runDelete uses the simpler {id, deleted} payload.
// ---------------------------------------------------------------------------
func TestDelete_Success_WithForce(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
opts := &DeleteOptions{Yes: true}
// Force=true short-circuits the confirm path; the prompter must not be
// consulted, so any value works.
require.NoError(t, runDelete(context.Background(), opts, nil, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc"))
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc"))
assert.Equal(t, "doc_abc", svc.got)
assert.Equal(t, 1, svc.calls)
@@ -45,7 +66,7 @@ func TestDelete_Success_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
opts := &DeleteOptions{Yes: true}
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc"))
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc"))
got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"doc_abc"`), "expected bare object; got %q", got)
@@ -56,7 +77,7 @@ func TestDelete_Success_JSON(t *testing.T) {
func TestDelete_NotFound_404(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, nil, svc, &testutil.ConfirmPrompter{}, "doc_missing")
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "doc_missing")
require.Error(t, err)
var typed *cmdutil.Error
@@ -67,18 +88,21 @@ func TestDelete_NotFound_404(t *testing.T) {
func TestDelete_HTTPError_500(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errors.New("HTTP error 500: internal")}
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, nil, svc, &testutil.ConfirmPrompter{}, "doc_x")
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "doc_x")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
// Single-id delete WrapHTTP-classifies the SDK error; HTTP 500 → server.error.
// (The multi-id path rolls up failures as operation.failed; this is the
// single-id path so it stays server.error.)
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
func TestDelete_ConfirmYes(t *testing.T) {
out, _ := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc")
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc")
require.NoError(t, err)
assert.Equal(t, 1, svc.calls, "user said yes ⇒ delete proceeds")
assert.Contains(t, out.String(), "✓")
@@ -87,7 +111,7 @@ func TestDelete_ConfirmYes(t *testing.T) {
func TestDelete_ConfirmNo(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc")
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc")
require.Error(t, err)
assert.Equal(t, 0, svc.calls, "user said no ⇒ SDK must NOT be called")
@@ -103,7 +127,7 @@ func TestDelete_ConfirmNo(t *testing.T) {
func TestDelete_AgentPrompterErrors(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
require.Error(t, err)
assert.Equal(t, 0, svc.calls)
@@ -119,7 +143,7 @@ func TestDelete_AgentPrompterErrors(t *testing.T) {
func TestDelete_NoYes_NonTTY_RequiresConfirmation(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -127,3 +151,156 @@ func TestDelete_NoYes_NonTTY_RequiresConfirmation(t *testing.T) {
assert.Equal(t, 0, svc.calls, "non-TTY without -y must not call DeleteKnowledge")
assert.Equal(t, 10, cmdutil.ExitCode(err))
}
// ---------------------------------------------------------------------------
// Multi-id tests (runMultiDelete, keep-going semantics)
// ---------------------------------------------------------------------------
func TestRunMultiDelete_AllSucceed(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
res, err := runMultiDelete(
context.Background(),
&DeleteOptions{Yes: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON},
svc,
&testutil.ConfirmPrompter{Answer: true},
[]string{"a", "b", "c"},
)
require.NoError(t, err)
assert.Equal(t, []string{"a", "b", "c"}, res.OK)
assert.Empty(t, res.Failed)
assert.Equal(t, 3, svc.calls)
}
func TestRunMultiDelete_KeepGoingOnError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{errFor: map[string]error{"doc_b": errors.New("not found")}}
res, err := runMultiDelete(
context.Background(),
&DeleteOptions{Yes: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON},
svc,
&testutil.ConfirmPrompter{Answer: true},
[]string{"doc_a", "doc_b", "doc_c"},
)
require.Error(t, err, "partial failure must return non-nil error (exit 1)")
assert.Equal(t, 3, svc.calls, "all ids must be attempted (keep-going)")
require.Len(t, res.OK, 2)
require.Len(t, res.Failed, 1)
assert.Equal(t, "doc_b", res.Failed[0].ID)
assert.Equal(t, "not found", res.Failed[0].Message)
// OK list must contain only successful ids
assert.Contains(t, res.OK, "doc_a")
assert.Contains(t, res.OK, "doc_c")
}
func TestRunMultiDelete_AllFail(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{errFor: map[string]error{
"x": errors.New("HTTP error 404: not found"),
"y": errors.New("HTTP error 403: forbidden"),
}}
res, err := runMultiDelete(
context.Background(),
&DeleteOptions{Yes: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON},
svc,
&testutil.ConfirmPrompter{Answer: true},
[]string{"x", "y"},
)
require.Error(t, err)
assert.Empty(t, res.OK)
assert.Len(t, res.Failed, 2)
}
func TestRunMultiDelete_ConfirmBatch_NonTTY_RequiresConfirmation(t *testing.T) {
_, _ = iostreams.SetForTest(t) // non-TTY
svc := &fakeDeleteSvc{}
_, err := runMultiDelete(
context.Background(),
&DeleteOptions{Yes: false},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON},
svc,
&testutil.ConfirmPrompter{Answer: false},
[]string{"a", "b"},
)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.Equal(t, 0, svc.calls, "must not call DeleteKnowledge without confirmation")
}
func TestRunMultiDelete_ConfirmBatch_TTY_UserAborts(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
_, err := runMultiDelete(
context.Background(),
&DeleteOptions{Yes: false},
&cmdutil.FormatOptions{Mode: cmdutil.FormatText},
svc,
&testutil.ConfirmPrompter{Answer: false},
[]string{"a", "b", "c"},
)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.Contains(t, errBuf.String(), "Aborted.")
assert.Equal(t, 0, svc.calls, "user aborted ⇒ SDK must NOT be called")
}
// ---------------------------------------------------------------------------
// Emit tests
// ---------------------------------------------------------------------------
func TestEmitMultiDelete_JSON(t *testing.T) {
var buf bytes.Buffer
res := &MultiDeleteResult{
OK: []string{"a", "b"},
Failed: []FailedItem{{ID: "c", Message: "x"}},
}
err := emitMultiDelete(res, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &buf)
require.NoError(t, err)
var got MultiDeleteResult
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, []string{"a", "b"}, got.OK)
require.Len(t, got.Failed, 1)
assert.Equal(t, "c", got.Failed[0].ID)
}
func TestEmitMultiDelete_Text(t *testing.T) {
var buf bytes.Buffer
res := &MultiDeleteResult{
OK: []string{"a"},
Failed: []FailedItem{{ID: "b", Message: "boom"}},
}
err := emitMultiDelete(res, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &buf)
require.NoError(t, err)
out := buf.String()
assert.Contains(t, out, "OK a")
assert.Contains(t, out, "FAIL b: boom")
}
func TestEmitMultiDelete_TextEmpty(t *testing.T) {
var buf bytes.Buffer
res := &MultiDeleteResult{OK: []string{"x", "y"}}
err := emitMultiDelete(res, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &buf)
require.NoError(t, err)
out := buf.String()
assert.Contains(t, out, "OK x")
assert.Contains(t, out, "OK y")
assert.NotContains(t, out, "FAIL")
}
func TestEmitMultiDelete_UnsupportedFormat(t *testing.T) {
var buf bytes.Buffer
res := &MultiDeleteResult{}
err := emitMultiDelete(res, &cmdutil.FormatOptions{Mode: "yaml"}, &buf)
require.Error(t, err)
assert.Contains(t, err.Error(), "yaml")
}
+10 -9
View File
@@ -17,7 +17,7 @@ import (
sdk "github.com/Tencent/WeKnora/client"
)
// docListFields enumerates the fields surfaced for `--json` discovery on
// docListFields enumerates the fields surfaced for `--format json` discovery on
// `doc list`. Filter applies to each Knowledge object in the bare array.
var docListFields = []string{
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
@@ -79,13 +79,14 @@ backend storage order is not guaranteed and varies between deployments.`,
Example: ` weknora doc list # uses project link / env
weknora doc list --kb a32a63ff-fb36-4874-bcaa-30f48570a694 # explicit UUID
weknora doc list --kb my-kb # resolved by name
weknora doc list --all-pages --json # walk every page`,
weknora doc list --all-pages --format json # walk every page`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
kbID, err := f.ResolveKB(c)
if err != nil {
return err
@@ -94,7 +95,7 @@ backend storage order is not guaranteed and varies between deployments.`,
if err != nil {
return err
}
return runList(c.Context(), opts, jopts, cli, kbID)
return runList(c.Context(), opts, fopts, cli, kbID)
},
}
// --kb is read by Factory.ResolveKB; declare it here so cobra parses the
@@ -110,11 +111,11 @@ backend storage order is not guaranteed and varies between deployments.`,
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Filter by tag association")
cmd.Flags().StringVar(&opts.StartTime, "start-time", "", "Include docs with updated_at >= this RFC3339 timestamp (e.g. 2006-01-02T15:04:05Z)")
cmd.Flags().StringVar(&opts.EndTime, "end-time", "", "Include docs with updated_at <= this RFC3339 timestamp (e.g. 2006-01-02T15:04:05Z)")
cmdutil.AddJSONFlags(cmd, docListFields)
cmdutil.AddFormatFlag(cmd, docListFields...)
return cmd
}
func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, svc ListService, kbID string) error {
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService, kbID string) error {
if opts.PageSize < 1 || opts.PageSize > 1000 {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
@@ -173,7 +174,7 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
accum = accum[:opts.Limit]
break
}
if int64(page*opts.PageSize) >= total || len(chunk) == 0 {
if int64(len(accum)) >= total || len(chunk) == 0 {
break
}
}
@@ -200,8 +201,8 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
items = items[:opts.Limit]
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, items)
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, items)
}
if len(items) == 0 {
+23 -23
View File
@@ -57,7 +57,7 @@ func TestList_Success_Human(t *testing.T) {
}
svc := &fakeListSvc{items: items, total: 2}
opts := &ListOptions{PageSize: 20}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "kb_xxx", svc.got.kbID)
assert.Equal(t, 1, svc.got.page)
@@ -76,7 +76,7 @@ func TestList_Success_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: []sdk.Knowledge{{ID: "doc1", FileName: "a.pdf"}}, total: 1}
opts := &ListOptions{PageSize: 20}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `[`), "expected bare JSON array, got %q", got)
@@ -89,7 +89,7 @@ func TestList_Empty_Human(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: nil, total: 0}
opts := &ListOptions{PageSize: 20}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Contains(t, out.String(), "(no documents)")
}
@@ -97,7 +97,7 @@ func TestList_Empty_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: nil, total: 0}
opts := &ListOptions{PageSize: 20}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
got := strings.TrimSpace(out.String())
assert.Equal(t, "[]", got, "empty list must serialize as bare `[]` not null")
@@ -107,7 +107,7 @@ func TestList_HTTPError_500(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{err: errors.New("HTTP error 500: internal")}
opts := &ListOptions{PageSize: 20}
err := runList(context.Background(), opts, nil, svc, "kb_xxx")
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
@@ -187,7 +187,7 @@ func TestList_SortByUpdatedDesc(t *testing.T) {
{ID: "new", FileName: "new.pdf", UpdatedAt: now.Add(-1 * time.Hour)},
}
svc := &fakeListSvc{items: items, total: 2}
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 20}, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
got := out.String()
newIdx := strings.Index(got, "new.pdf")
@@ -221,7 +221,7 @@ func TestList_StatusFilter_ForwardedToSDK(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Status: "failed"}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "failed", svc.got.filter.ParseStatus,
"--status must be forwarded as filter.ParseStatus for server-side filtering")
}
@@ -231,7 +231,7 @@ func TestList_StatusFilter_RejectsUnknownValue(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Status: "bogus"}
err := runList(context.Background(), opts, nil, svc, "kb_xxx")
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -246,7 +246,7 @@ func TestList_StatusFilter_AcceptsAllEnumValues(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Status: v}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"),
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"),
"status=%q should be accepted", v)
}
}
@@ -292,7 +292,7 @@ func TestList_Limit_LessThanPageSize_SlicesToLimit(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: makeDocs(20), total: 20}
opts := &ListOptions{PageSize: 20, Limit: 5}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
body := out.String()
// Count occurrences of "id":"doc_" - should be exactly 5.
got := strings.Count(body, `"id":"doc_`)
@@ -303,7 +303,7 @@ func TestList_Limit_GreaterThanPageSize_NoCap(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: makeDocs(10), total: 10}
opts := &ListOptions{PageSize: 10, Limit: 50}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
got := strings.Count(out.String(), `"id":"doc_`)
assert.Equal(t, 10, got, "--limit 50 with page-size 10 + 10 items returns all 10")
}
@@ -311,7 +311,7 @@ func TestList_Limit_GreaterThanPageSize_NoCap(t *testing.T) {
func TestList_Limit_Negative_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
opts := &ListOptions{PageSize: 20, Limit: -1}
err := runList(context.Background(), opts, nil, &fakeListSvc{}, "kb_xxx")
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -322,7 +322,7 @@ func TestList_AllPages_WalksAllServerPages(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &pagedDocSvc{all: makeDocs(45)}
opts := &ListOptions{PageSize: 20, AllPages: true}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
// 45 items / page_size 20 = 3 pages: 20 + 20 + 5.
assert.Equal(t, []int{1, 2, 3}, svc.calls)
got := strings.Count(out.String(), `"id":"doc_`)
@@ -333,7 +333,7 @@ func TestList_AllPages_WithLimit_StopsAtLimit(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &pagedDocSvc{all: makeDocs(200)}
opts := &ListOptions{PageSize: 20, AllPages: true, Limit: 50}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
got := strings.Count(out.String(), `"id":"doc_`)
assert.Equal(t, 50, got, "--limit 50 with --all-pages should stop after 50 items")
// Should have called pages 1..3 (60 items) then capped at 50.
@@ -346,7 +346,7 @@ func TestList_Keyword_PassedToFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Keyword: "spec"}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "spec", svc.got.filter.Keyword)
}
@@ -354,7 +354,7 @@ func TestList_FileType_PassedToFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, FileType: "pdf"}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "pdf", svc.got.filter.FileType)
}
@@ -362,7 +362,7 @@ func TestList_Source_PassedToFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Source: "api"}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "api", svc.got.filter.Source)
}
@@ -370,7 +370,7 @@ func TestList_TagID_PassedToFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, TagID: "tag_42"}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "tag_42", svc.got.filter.TagID)
}
@@ -379,7 +379,7 @@ func TestList_StartTime_RFC3339Parses(t *testing.T) {
svc := &fakeListSvc{}
want := "2026-05-01T00:00:00Z"
opts := &ListOptions{PageSize: 20, StartTime: want}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
parsed, err := time.Parse(time.RFC3339, want)
require.NoError(t, err)
assert.True(t, svc.got.filter.StartTime.Equal(parsed),
@@ -392,7 +392,7 @@ func TestList_EndTime_RFC3339Parses(t *testing.T) {
svc := &fakeListSvc{}
want := "2026-06-30T23:59:59Z"
opts := &ListOptions{PageSize: 20, EndTime: want}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
parsed, err := time.Parse(time.RFC3339, want)
require.NoError(t, err)
assert.True(t, svc.got.filter.EndTime.Equal(parsed))
@@ -401,7 +401,7 @@ func TestList_EndTime_RFC3339Parses(t *testing.T) {
func TestList_StartTime_InvalidFormat_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
opts := &ListOptions{PageSize: 20, StartTime: "tomorrow"}
err := runList(context.Background(), opts, nil, &fakeListSvc{}, "kb_xxx")
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -413,7 +413,7 @@ func TestList_StartTime_InvalidFormat_Rejected(t *testing.T) {
func TestList_EndTime_InvalidFormat_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
opts := &ListOptions{PageSize: 20, EndTime: "2026-05-01"} // date-only, not RFC3339
err := runList(context.Background(), opts, nil, &fakeListSvc{}, "kb_xxx")
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -436,7 +436,7 @@ func TestList_AllFiltersCombined(t *testing.T) {
StartTime: "2026-01-01T00:00:00Z",
EndTime: "2026-12-31T23:59:59Z",
}
require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
f := svc.got.filter
assert.Equal(t, "completed", f.ParseStatus)
assert.Equal(t, "spec", f.Keyword)
+19 -15
View File
@@ -20,7 +20,7 @@ import (
// for analytics. Users can override via --channel for cross-tool replay.
const uploadChannel = "api"
// docUploadFields enumerates the fields surfaced for `--json` discovery on
// docUploadFields enumerates the fields surfaced for `--format json` discovery on
// `doc upload`. The single-file upload result is the full Knowledge struct;
// these are its top-level json tags.
var docUploadFields = []string{
@@ -120,10 +120,11 @@ Passing any of those without --from-url is rejected as input.invalid_argument.`,
weknora doc upload --from-url https://example.com/article.html --name "Q3 Article" --tag-id tag_abc`,
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Translate the tri-state --enable-multimodel flag into the
// *bool the SDK expects. Cobra's BoolVar can't distinguish
// "unset" from "false", so we register a String flag and read
@@ -150,14 +151,14 @@ Passing any of those without --from-url is rejected as input.invalid_argument.`,
switch {
case opts.FromURL != "":
return runUploadFromURL(c.Context(), opts, jopts, cli, kbID)
return runUploadFromURL(c.Context(), opts, fopts, cli, kbID)
case opts.Recursive:
return runUploadRecursive(c.Context(), opts, jopts, cli, kbID, args[0])
return runUploadRecursive(c.Context(), opts, fopts, cli, kbID, args[0])
default:
if err := validateUploadPath(args[0]); err != nil {
return err
}
return runUpload(c.Context(), opts, jopts, cli, kbID, args[0])
return runUpload(c.Context(), opts, fopts, cli, kbID, args[0])
}
},
}
@@ -175,7 +176,7 @@ Passing any of those without --from-url is rejected as input.invalid_argument.`,
cmd.Flags().StringVar(&opts.Title, "title", "", "Display title for the new entry (--from-url only)")
cmd.Flags().StringVar(&opts.FileType, "file-type", "", "File-type hint such as \"pdf\" when the URL has no extension (--from-url only)")
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Tag id to associate with the new entry (--from-url only)")
cmdutil.AddJSONFlags(cmd, docUploadFields)
cmdutil.AddFormatFlag(cmd, docUploadFields...)
return cmd
}
@@ -254,8 +255,11 @@ func validateUploadFlags(opts *UploadOptions, args []string) error {
return cmdutil.ValidateHTTPURL("--from-url", opts.FromURL)
}
if !hasPath {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"a file path is required (or pass --from-url)")
// Wrap as FlagError so the exit code (2) matches what cobra's own
// MinimumNArgs(1) would emit — consistent with every other command
// that requires a positional argument.
return cmdutil.NewFlagError(errors.New(
"a file path is required (or pass --from-url)"))
}
return rejectURLOnlyFlags(opts)
}
@@ -286,7 +290,7 @@ func rejectURLOnlyFlags(opts *UploadOptions) error {
// Server-side knobs (--enable-multimodel, --metadata via Title/TagID/FileType)
// propagate when set; the SDK request struct omits empty fields via
// `json:",omitempty"` tags so wire payload stays minimal.
func runUploadFromURL(ctx context.Context, opts *UploadOptions, jopts *cmdutil.JSONOptions, svc UploadService, kbID string) error {
func runUploadFromURL(ctx context.Context, opts *UploadOptions, fopts *cmdutil.FormatOptions, svc UploadService, kbID string) error {
req := sdk.CreateKnowledgeFromURLRequest{
URL: opts.FromURL,
FileName: opts.Name,
@@ -309,7 +313,7 @@ func runUploadFromURL(ctx context.Context, opts *UploadOptions, jopts *cmdutil.J
return cmdutil.WrapHTTP(err, "ingest URL %s", opts.FromURL)
}
return renderUploadSuccess(k, jopts, "Ingested", opts.Name, opts.FromURL)
return renderUploadSuccess(k, fopts, "Ingested", opts.Name, opts.FromURL)
}
// renderUploadSuccess emits the post-upload result. JSON path is the bare
@@ -317,9 +321,9 @@ func runUploadFromURL(ctx context.Context, opts *UploadOptions, jopts *cmdutil.J
// file upload and URL ingest; humanVerb varies (uploaded/ingested) and
// fallbackDisplay covers the case when the server-recorded file_name is
// blank (URL ingest pre-redirect).
func renderUploadSuccess(k *sdk.Knowledge, jopts *cmdutil.JSONOptions, humanVerb, customName, fallbackDisplay string) error {
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, k)
func renderUploadSuccess(k *sdk.Knowledge, fopts *cmdutil.FormatOptions, humanVerb, customName, fallbackDisplay string) error {
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, k)
}
displayed := customName
if displayed == "" {
@@ -353,7 +357,7 @@ func validateUploadPath(path string) error {
return nil
}
func runUpload(ctx context.Context, opts *UploadOptions, jopts *cmdutil.JSONOptions, svc UploadService, kbID, path string) error {
func runUpload(ctx context.Context, opts *UploadOptions, fopts *cmdutil.FormatOptions, svc UploadService, kbID, path string) error {
meta, err := parseMetadataKV(opts.Metadata)
if err != nil {
return err
@@ -369,5 +373,5 @@ func runUpload(ctx context.Context, opts *UploadOptions, jopts *cmdutil.JSONOpti
}
return cmdutil.WrapHTTP(err, "upload %s", path)
}
return renderUploadSuccess(k, jopts, "Uploaded", opts.Name, path)
return renderUploadSuccess(k, fopts, "Uploaded", opts.Name, path)
}
+11 -11
View File
@@ -24,7 +24,7 @@ type uploadOutcome struct {
// in one run. Exit semantics: nil error on full success, a typed *cmdutil.Error
// when ≥1 file failed (the typed code mirrors the first failure's
// classification so callers can still branch).
func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil.JSONOptions, svc UploadService, kbID, dir string) error {
func runUploadRecursive(ctx context.Context, opts *UploadOptions, fopts *cmdutil.FormatOptions, svc UploadService, kbID, dir string) error {
if opts.Name != "" {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
@@ -74,8 +74,8 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "walk %s", dir)
}
if len(matches) == 0 {
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, recursiveResult{KBID: kbID})
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, recursiveResult{KBID: kbID})
}
fmt.Fprintf(iostreams.IO.Out, "(no files matched %q under %s)\n", opts.Glob, dir)
return nil
@@ -93,8 +93,8 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
}
failed = append(failed, uploadOutcome{Path: p, Error: err.Error()})
// Per-file progress lines are human progress signal; suppress
// under --json so they don't precede the JSON object on stdout.
if !jopts.Enabled() {
// under --format json so they don't precede the JSON object on stdout.
if !fopts.WantsJSON() {
fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err)
}
continue
@@ -104,14 +104,14 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
id = k.ID
}
uploaded = append(uploaded, uploadOutcome{Path: p, ID: id})
if !jopts.Enabled() {
if !fopts.WantsJSON() {
fmt.Fprintf(iostreams.IO.Out, "OK %s (id: %s)\n", filepath.Base(p), id)
}
}
if jopts.Enabled() {
if fopts.WantsJSON() {
result := recursiveResult{KBID: kbID, Uploaded: uploaded, Failed: failed}
if err := jopts.Emit(iostreams.IO.Out, result); err != nil {
if err := fopts.Emit(iostreams.IO.Out, result); err != nil {
return err
}
} else {
@@ -119,21 +119,21 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
}
if len(failed) > 0 {
// Silent on the --json path: the success object above already
// Silent on the --format json path: the success object above already
// carries per-file uploaded[]/failed[] detail; without Silent the
// root error handler would print to stderr in addition. ExitCode
// still walks Code so the typed exit-code-by-class contract holds.
return &cmdutil.Error{
Code: firstFailCode,
Message: fmt.Sprintf("%d of %d uploads failed", len(failed), len(matches)),
Silent: jopts.Enabled(),
Silent: fopts.WantsJSON(),
}
}
return nil
}
// recursiveResult is the JSON shape emitted under data when --recursive is
// combined with --json. Mirrors the human-mode per-file output: a list of
// combined with --format json. Mirrors the human-mode per-file output: a list of
// successes (Uploaded) and a list of failures (Failed), each with the
// originating path so agents can re-try only the failed entries.
type recursiveResult struct {
+13 -13
View File
@@ -77,7 +77,7 @@ func TestUploadRecursive_WalksAllFiles(t *testing.T) {
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*"}
require.NoError(t, runUploadRecursive(context.Background(), opts, nil, svc, "kb_xxx", dir))
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
sort.Strings(svc.called)
assert.Equal(t, []string{"a.pdf", "b.pdf", "c.pdf"}, svc.called)
@@ -94,7 +94,7 @@ func TestUploadRecursive_GlobFilter(t *testing.T) {
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*.pdf"}
require.NoError(t, runUploadRecursive(context.Background(), opts, nil, svc, "kb_xxx", dir))
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
sort.Strings(svc.called)
assert.Equal(t, []string{"doc.pdf", "keep.pdf"}, svc.called)
@@ -112,7 +112,7 @@ func TestUploadRecursive_PartialFailure_Exits1(t *testing.T) {
"bad.pdf": {err: errors.New("HTTP error 500: internal")},
}}
opts := &UploadOptions{Recursive: true, Glob: "*"}
err := runUploadRecursive(context.Background(), opts, nil, svc, "kb_xxx", dir)
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir)
require.Error(t, err)
var typed *cmdutil.Error
@@ -135,7 +135,7 @@ func TestUploadRecursive_NoMatches(t *testing.T) {
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*.pdf"}
require.NoError(t, runUploadRecursive(context.Background(), opts, nil, svc, "kb_xxx", dir))
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
assert.Len(t, svc.called, 0)
assert.Contains(t, strings.ToLower(out.String()), "no files matched")
}
@@ -144,7 +144,7 @@ func TestUploadRecursive_NotADirectory(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "single.pdf")
svc := &scriptedUploadSvc{}
err := runUploadRecursive(context.Background(), &UploadOptions{Recursive: true, Glob: "*"}, nil, svc, "kb_xxx", path)
err := runUploadRecursive(context.Background(), &UploadOptions{Recursive: true, Glob: "*"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -158,7 +158,7 @@ func TestUploadRecursive_RejectsNameFlag(t *testing.T) {
mkTree(t, dir, "a.pdf")
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*", Name: "single-name.pdf"}
err := runUploadRecursive(context.Background(), opts, nil, svc, "kb_xxx", dir)
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -180,7 +180,7 @@ func TestUploadRecursive_PropagatesMultimodelAndMetadata(t *testing.T) {
Metadata: []string{"team=alpha"},
Channel: "browser_extension",
}
require.NoError(t, runUploadRecursive(context.Background(), opts, nil, svc, "kb_xxx", dir))
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
require.NotNil(t, svc.lastEnableMultimodel)
assert.True(t, *svc.lastEnableMultimodel)
@@ -195,7 +195,7 @@ func TestUploadRecursive_MetadataInvalid_NoCalls(t *testing.T) {
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*", Metadata: []string{"badformat"}}
err := runUploadRecursive(context.Background(), opts, nil, svc, "kb_xxx", dir)
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -218,7 +218,7 @@ func TestUploadRecursive_RejectsURLOnlyFlags(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
svc := &scriptedUploadSvc{}
err := runUploadRecursive(context.Background(), tc.opts, nil, svc, "kb_xxx", dir)
err := runUploadRecursive(context.Background(), tc.opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -240,7 +240,7 @@ func TestUploadRecursive_JSON_BareObject(t *testing.T) {
"bad.pdf": {err: errors.New("HTTP error 500: internal")},
}}
opts := &UploadOptions{Recursive: true, Glob: "*"}
err := runUploadRecursive(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx", dir)
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx", dir)
require.Error(t, err) // partial failure → typed error
body := out.String()
@@ -251,11 +251,11 @@ func TestUploadRecursive_JSON_BareObject(t *testing.T) {
assert.Contains(t, body, `bad.pdf`)
assert.NotContains(t, body, `"ok":`, "bare output must not carry envelope keys")
// --json must emit exactly ONE JSON document. Per-file "FAIL"/"OK"
// --format json must emit exactly ONE JSON document. Per-file "FAIL"/"OK"
// progress lines belong on the human path; the typed error is Silent so
// the root handler doesn't write anything additional to stdout.
assert.NotContains(t, body, "FAIL ", "per-file plain lines must not appear under --json")
assert.NotContains(t, body, "OK ", "per-file plain lines must not appear under --json")
assert.NotContains(t, body, "FAIL ", "per-file plain lines must not appear under --format json")
assert.NotContains(t, body, "OK ", "per-file plain lines must not appear under --format json")
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
+30 -28
View File
@@ -69,7 +69,7 @@ func TestUpload_Success_Human(t *testing.T) {
path := writeTempFile(t, "report.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_99", FileName: "report.pdf"}}
opts := &UploadOptions{}
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, "kb_xxx", svc.got.kbID)
assert.Equal(t, path, svc.got.filePath)
@@ -91,7 +91,7 @@ func TestUpload_Success_CustomName(t *testing.T) {
path := writeTempFile(t, "q3.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_88", FileName: "q3.pdf"}}
opts := &UploadOptions{Name: "Q3 Marketing Report.pdf"}
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, "Q3 Marketing Report.pdf", svc.got.customName)
}
@@ -100,7 +100,7 @@ func TestUpload_Success_JSON(t *testing.T) {
path := writeTempFile(t, "a.md")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_77", FileName: "a.md"}}
opts := &UploadOptions{}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx", path))
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx", path))
got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"doc_77"`), "expected bare Knowledge object; got %q", got)
@@ -112,7 +112,7 @@ func TestUpload_HTTPError_500(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "x.txt")
svc := &fakeUploadSvc{err: errors.New("HTTP error 500: internal")}
err := runUpload(context.Background(), &UploadOptions{}, nil, svc, "kb_xxx", path)
err := runUpload(context.Background(), &UploadOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
@@ -124,7 +124,7 @@ func TestUpload_HTTPError_409Conflict(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "dup.pdf")
svc := &fakeUploadSvc{err: errors.New("HTTP error 409: file exists")}
err := runUpload(context.Background(), &UploadOptions{}, nil, svc, "kb_xxx", path)
err := runUpload(context.Background(), &UploadOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
@@ -143,7 +143,7 @@ func TestUpload_DuplicateFileMaps_resource_already_exists(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "dup.md")
svc := &fakeUploadSvc{err: sdk.ErrDuplicateFile}
err := runUpload(context.Background(), &UploadOptions{}, nil, svc, "kb_xxx", path)
err := runUpload(context.Background(), &UploadOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
@@ -193,7 +193,7 @@ func TestUploadFromURL_Success_Human(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_url_1", FileName: "whitepaper.pdf"}}
opts := &UploadOptions{FromURL: "https://example.com/whitepaper.pdf"}
require.NoError(t, runUploadFromURL(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runUploadFromURL(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "kb_xxx", svc.got.kbID)
assert.Equal(t, "https://example.com/whitepaper.pdf", svc.got.urlReq.URL)
@@ -206,7 +206,7 @@ func TestUploadFromURL_WithName_Passes_AsFileName(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_url_2"}}
opts := &UploadOptions{FromURL: "https://example.com/article.html", Name: "Q3 Article"}
require.NoError(t, runUploadFromURL(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runUploadFromURL(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "Q3 Article", svc.got.urlReq.FileName,
"--name must be forwarded as FileName (server uses it for file-vs-crawl mode hint)")
}
@@ -214,9 +214,9 @@ func TestUploadFromURL_WithName_Passes_AsFileName(t *testing.T) {
func TestUploadFromURL_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_url_3", FileName: "ok.pdf"}}
jopts := &cmdutil.JSONOptions{}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
require.NoError(t, runUploadFromURL(context.Background(),
&UploadOptions{FromURL: "https://example.com/ok.pdf"}, jopts, svc, "kb_xxx"))
&UploadOptions{FromURL: "https://example.com/ok.pdf"}, fopts, svc, "kb_xxx"))
got := out.String()
assert.Contains(t, got, `"id":"doc_url_3"`)
assert.NotContains(t, got, `"ok":`)
@@ -230,7 +230,7 @@ func TestUploadFromURL_DuplicateURLMaps_resource_already_exists(t *testing.T) {
urlErr: sdk.ErrDuplicateURL,
}
err := runUploadFromURL(context.Background(),
&UploadOptions{FromURL: "https://example.com/dup.pdf"}, nil, svc, "kb_xxx")
&UploadOptions{FromURL: "https://example.com/dup.pdf"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -288,9 +288,11 @@ func TestValidateUploadFlags_FromURL_NoHost(t *testing.T) {
func TestValidateUploadFlags_NoPathOrURL_Rejected(t *testing.T) {
err := validateUploadFlags(&UploadOptions{}, nil)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
// Missing required input wraps as FlagError so the exit code (2)
// matches cobra's MinimumNArgs(1) for commands taking a positional.
var fe *cmdutil.FlagError
require.ErrorAs(t, err, &fe, "expected FlagError so exit code maps to 2")
assert.Equal(t, 2, cmdutil.ExitCode(err))
}
// --- C10 expanded flags: multimodel / metadata / channel / URL-mode extras ---
@@ -301,7 +303,7 @@ func TestUpload_EnableMultimodel_Set_True(t *testing.T) {
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_mm", FileName: "mm.pdf"}}
mm := true
opts := &UploadOptions{EnableMultimodel: &mm}
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
require.NotNil(t, svc.got.enableMultimodel, "expected non-nil *bool when flag set")
assert.True(t, *svc.got.enableMultimodel)
}
@@ -312,7 +314,7 @@ func TestUpload_EnableMultimodel_Set_False(t *testing.T) {
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_mm", FileName: "mm.pdf"}}
mm := false
opts := &UploadOptions{EnableMultimodel: &mm}
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
require.NotNil(t, svc.got.enableMultimodel, "explicit false must still surface as non-nil *bool")
assert.False(t, *svc.got.enableMultimodel)
}
@@ -356,7 +358,7 @@ func TestUpload_Metadata_ParseKV(t *testing.T) {
path := writeTempFile(t, "m.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
opts := &UploadOptions{Metadata: []string{"foo=bar", "baz=qux"}}
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, map[string]string{"foo": "bar", "baz": "qux"}, svc.got.metadata)
}
@@ -365,7 +367,7 @@ func TestUpload_Metadata_EmptyValueAllowed(t *testing.T) {
path := writeTempFile(t, "m.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
opts := &UploadOptions{Metadata: []string{"foo="}}
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, map[string]string{"foo": ""}, svc.got.metadata)
}
@@ -374,7 +376,7 @@ func TestUpload_Metadata_LastWins(t *testing.T) {
path := writeTempFile(t, "m.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
opts := &UploadOptions{Metadata: []string{"k=v1", "k=v2"}}
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, map[string]string{"k": "v2"}, svc.got.metadata)
}
@@ -383,7 +385,7 @@ func TestUpload_Metadata_InvalidFormat_NoEquals(t *testing.T) {
path := writeTempFile(t, "m.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
opts := &UploadOptions{Metadata: []string{"foo"}}
err := runUpload(context.Background(), opts, nil, svc, "kb_xxx", path)
err := runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -395,7 +397,7 @@ func TestUpload_Metadata_InvalidFormat_EmptyKey(t *testing.T) {
path := writeTempFile(t, "m.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
opts := &UploadOptions{Metadata: []string{"=bar"}}
err := runUpload(context.Background(), opts, nil, svc, "kb_xxx", path)
err := runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -407,7 +409,7 @@ func TestUpload_Channel_Override(t *testing.T) {
path := writeTempFile(t, "c.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_c", FileName: "c.pdf"}}
opts := &UploadOptions{Channel: "browser_extension"}
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, "browser_extension", svc.got.channel)
}
@@ -417,7 +419,7 @@ func TestUpload_Channel_DefaultStillAPI(t *testing.T) {
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_c", FileName: "c.pdf"}}
// Empty Channel is the runUpload contract for "use default".
opts := &UploadOptions{}
require.NoError(t, runUpload(context.Background(), opts, nil, svc, "kb_xxx", path))
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, uploadChannel, svc.got.channel)
}
@@ -427,7 +429,7 @@ func TestUploadFromURL_Title(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_u"}}
opts := &UploadOptions{FromURL: "https://example.com/a.pdf", Title: "My Title"}
require.NoError(t, runUploadFromURL(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runUploadFromURL(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "My Title", svc.got.urlReq.Title)
}
@@ -435,7 +437,7 @@ func TestUploadFromURL_FileType(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_u"}}
opts := &UploadOptions{FromURL: "https://example.com/no-ext", FileType: "pdf"}
require.NoError(t, runUploadFromURL(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runUploadFromURL(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "pdf", svc.got.urlReq.FileType)
}
@@ -443,7 +445,7 @@ func TestUploadFromURL_TagID(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_u"}}
opts := &UploadOptions{FromURL: "https://example.com/a.pdf", TagID: "tag_99"}
require.NoError(t, runUploadFromURL(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runUploadFromURL(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "tag_99", svc.got.urlReq.TagID)
}
@@ -452,7 +454,7 @@ func TestUploadFromURL_EnableMultimodel_Forwarded(t *testing.T) {
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_u"}}
mm := true
opts := &UploadOptions{FromURL: "https://example.com/a.pdf", EnableMultimodel: &mm}
require.NoError(t, runUploadFromURL(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runUploadFromURL(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
require.NotNil(t, svc.got.urlReq.EnableMultimodel)
assert.True(t, *svc.got.urlReq.EnableMultimodel)
}
@@ -461,7 +463,7 @@ func TestUploadFromURL_Channel_Override(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_u"}}
opts := &UploadOptions{FromURL: "https://example.com/a.pdf", Channel: "web"}
require.NoError(t, runUploadFromURL(context.Background(), opts, nil, svc, "kb_xxx"))
require.NoError(t, runUploadFromURL(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "web", svc.got.urlReq.Channel)
}
+13 -7
View File
@@ -15,7 +15,7 @@ import (
sdk "github.com/Tencent/WeKnora/client"
)
// kbListFields enumerates the fields surfaced for `--json` discovery on
// kbListFields enumerates the fields surfaced for `--format json` discovery on
// `kb list`. Nested config structs (chunking / image / FAQ / VLM / storage
// / extract) are intentionally omitted - users wanting those can use `--jq`
// against the full object.
@@ -51,24 +51,30 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
Long: `List knowledge bases visible to the active context, sorted by most recently updated. Pass --pinned to restrict to pinned KBs.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runList(c.Context(), opts, jopts, cli)
return runList(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().BoolVar(&opts.Pinned, "pinned", false, "Only show pinned knowledge bases")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (0 = no cap, 1..10000 = explicit)")
cmdutil.AddJSONFlags(cmd, kbListFields)
cmdutil.AddFormatFlag(cmd, kbListFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "List knowledge bases in the current tenant. Agents should use --format json to consume a stable {data, total, page, page_size} response.",
Examples: []string{"weknora kb list --format json"},
Output: "array of KnowledgeBase objects with id, name, is_pinned, type, embedding_model_id",
})
return cmd
}
func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, svc ListService) error {
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
if opts.Limit < 0 || opts.Limit > 10000 {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
@@ -102,8 +108,8 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
items = items[:opts.Limit]
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, items)
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, items)
}
if len(items) == 0 {
+19 -22
View File
@@ -25,7 +25,7 @@ func (f *fakeListSvc) ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBa
func TestList_Empty_Human(t *testing.T) {
out, _ := iostreams.SetForTest(t)
if err := runList(context.Background(), &ListOptions{}, nil, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
if err := runList(context.Background(), &ListOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
t.Fatalf("runList: %v", err)
}
if !strings.Contains(out.String(), "(no knowledge bases)") {
@@ -35,8 +35,8 @@ func TestList_Empty_Human(t *testing.T) {
func TestList_Empty_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
jopts := &cmdutil.JSONOptions{}
if err := runList(context.Background(), &ListOptions{}, jopts, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := runList(context.Background(), &ListOptions{}, fopts, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
t.Fatalf("runList: %v", err)
}
got := strings.TrimSpace(out.String())
@@ -52,7 +52,7 @@ func TestList_NonEmpty_Human_RenderColumns(t *testing.T) {
{ID: "kb1", Name: "Marketing", KnowledgeCount: 5, UpdatedAt: now.Add(-3 * time.Hour)},
{ID: "kb2", Name: "Engineering", KnowledgeCount: 1, UpdatedAt: now.Add(-2 * 24 * time.Hour)},
}
if err := runList(context.Background(), &ListOptions{}, nil, &fakeListSvc{items: items}); err != nil {
if err := runList(context.Background(), &ListOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
got := out.String()
@@ -63,24 +63,21 @@ func TestList_NonEmpty_Human_RenderColumns(t *testing.T) {
}
}
func TestList_JSON_FieldFilter(t *testing.T) {
func TestList_JSON_JQProjection(t *testing.T) {
out, _ := iostreams.SetForTest(t)
now := time.Now()
items := []sdk.KnowledgeBase{
{ID: "kb1", Name: "Marketing", Description: "MKT desc", UpdatedAt: now},
}
jopts := &cmdutil.JSONOptions{Fields: []string{"id", "name"}}
if err := runList(context.Background(), &ListOptions{}, jopts, &fakeListSvc{items: items}); err != nil {
// --jq is the canonical projection mechanism in v0.6+.
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".[] | {id, name}"}
if err := runList(context.Background(), &ListOptions{}, fopts, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
var got []map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
var item map[string]any
if err := json.Unmarshal(out.Bytes(), &item); err != nil {
t.Fatalf("parse: %v\n%s", err, out.String())
}
if len(got) != 1 {
t.Fatalf("expected 1 item, got %d", len(got))
}
item := got[0]
if item["id"] != "kb1" || item["name"] != "Marketing" {
t.Errorf("kept fields wrong: %+v", item)
}
@@ -96,8 +93,8 @@ func TestList_JSON_JQ(t *testing.T) {
{ID: "kb1", Name: "Marketing", UpdatedAt: now},
{ID: "kb2", Name: "Engineering", UpdatedAt: now.Add(-time.Hour)},
}
jopts := &cmdutil.JSONOptions{JQ: ". | length"}
if err := runList(context.Background(), &ListOptions{}, jopts, &fakeListSvc{items: items}); err != nil {
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ". | length"}
if err := runList(context.Background(), &ListOptions{}, fopts, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "2" {
@@ -113,7 +110,7 @@ func TestList_PinnedFilter(t *testing.T) {
{ID: "kb2", Name: "Engineering", IsPinned: false, UpdatedAt: now.Add(-time.Hour)},
{ID: "kb3", Name: "Finance", IsPinned: true, UpdatedAt: now.Add(-2 * time.Hour)},
}
if err := runList(context.Background(), &ListOptions{Pinned: true}, nil, &fakeListSvc{items: items}); err != nil {
if err := runList(context.Background(), &ListOptions{Pinned: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
got := out.String()
@@ -130,7 +127,7 @@ func TestList_PinnedFilter_NoPinned_HumanMessage(t *testing.T) {
items := []sdk.KnowledgeBase{
{ID: "kb1", Name: "Marketing", IsPinned: false, UpdatedAt: time.Now()},
}
if err := runList(context.Background(), &ListOptions{Pinned: true}, nil, &fakeListSvc{items: items}); err != nil {
if err := runList(context.Background(), &ListOptions{Pinned: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
if !strings.Contains(out.String(), "(no pinned knowledge bases)") {
@@ -155,8 +152,8 @@ func makeKBs(n int) []sdk.KnowledgeBase {
func TestList_Limit_CapsResults(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: makeKBs(20)}
jopts := &cmdutil.JSONOptions{}
if err := runList(context.Background(), &ListOptions{Limit: 5}, jopts, svc); err != nil {
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := runList(context.Background(), &ListOptions{Limit: 5}, fopts, svc); err != nil {
t.Fatalf("runList: %v", err)
}
got := strings.Count(out.String(), `"id":"kb_`)
@@ -168,8 +165,8 @@ func TestList_Limit_CapsResults(t *testing.T) {
func TestList_Limit_Zero_NoCap(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: makeKBs(7)}
jopts := &cmdutil.JSONOptions{}
if err := runList(context.Background(), &ListOptions{Limit: 0}, jopts, svc); err != nil {
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := runList(context.Background(), &ListOptions{Limit: 0}, fopts, svc); err != nil {
t.Fatalf("runList: %v", err)
}
got := strings.Count(out.String(), `"id":"kb_`)
@@ -180,7 +177,7 @@ func TestList_Limit_Zero_NoCap(t *testing.T) {
func TestList_Limit_Negative_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
err := runList(context.Background(), &ListOptions{Limit: -1}, nil, &fakeListSvc{items: makeKBs(3)})
err := runList(context.Background(), &ListOptions{Limit: -1}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: makeKBs(3)})
if err == nil {
t.Fatal("expected error for negative --limit")
}
+8 -7
View File
@@ -12,7 +12,7 @@ import (
sdk "github.com/Tencent/WeKnora/client"
)
// chunksFields enumerates the fields surfaced for `--json` discovery on
// chunksFields enumerates the fields surfaced for `--format json` discovery on
// `search chunks`. Filter applies to each SearchResult object in the bare
// array.
var chunksFields = []string{
@@ -64,10 +64,11 @@ func NewCmdChunks(f *cmdutil.Factory) *cobra.Command {
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
@@ -77,7 +78,7 @@ func NewCmdChunks(f *cmdutil.Factory) *cobra.Command {
return err
}
opts.KBID = kbID
return runChunks(c.Context(), opts, jopts, cli)
return runChunks(c.Context(), opts, fopts, cli)
},
}
bindChunksFlags(cmd, opts)
@@ -94,7 +95,7 @@ func bindChunksFlags(cmd *cobra.Command, opts *ChunksOptions) {
cmd.Flags().Float64Var(&opts.KeywordThreshold, "keyword-threshold", 0, "Keyword retrieval score floor (per-channel, pre-fusion); 0 = no filter")
cmd.Flags().BoolVar(&opts.NoVector, "no-vector", false, "Disable the vector channel")
cmd.Flags().BoolVar(&opts.NoKeyword, "no-keyword", false, "Disable the keyword channel")
cmdutil.AddJSONFlags(cmd, chunksFields)
cmdutil.AddFormatFlag(cmd, chunksFields...)
}
// validate checks the option set before any SDK call. Limit bounds are
@@ -110,7 +111,7 @@ func (o *ChunksOptions) validate() error {
return nil
}
func runChunks(ctx context.Context, opts *ChunksOptions, jopts *cmdutil.JSONOptions, svc ChunksService) error {
func runChunks(ctx context.Context, opts *ChunksOptions, fopts *cmdutil.FormatOptions, svc ChunksService) error {
if err := opts.validate(); err != nil {
return err
}
@@ -140,11 +141,11 @@ func runChunks(ctx context.Context, opts *ChunksOptions, jopts *cmdutil.JSONOpti
results = results[:opts.Limit]
}
if jopts.Enabled() {
if fopts.WantsJSON() {
if results == nil {
results = []*sdk.SearchResult{}
}
return jopts.Emit(iostreams.IO.Out, results)
return fopts.Emit(iostreams.IO.Out, results)
}
return renderChunkResults(results, opts.KBID)
}
+12 -12
View File
@@ -34,7 +34,7 @@ func TestRunSearch_HumanOutput(t *testing.T) {
{Score: 0.81, Content: "second chunk", KnowledgeID: "doc-2", MatchType: sdk.MatchTypeKeyword},
}}
opts := &ChunksOptions{Query: "hello", KBID: "kb_abc", Limit: 5}
require.NoError(t, runChunks(context.Background(), opts, nil, svc))
require.NoError(t, runChunks(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Equal(t, "kb_abc", svc.gotKB)
assert.Equal(t, "hello", svc.gotQ)
@@ -46,13 +46,13 @@ func TestRunSearch_HumanOutput(t *testing.T) {
// JSON output must surface match_type so machine consumers / agents can
// reason about retrieval channels without re-implementing the wire format.
// (Human renderer keeps default minimal - diagnostic info opt-in via --json.)
// (Human renderer keeps default minimal - diagnostic info opt-in via --format json.)
func TestRunSearch_JSONIncludesMatchType(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: []*sdk.SearchResult{
{Score: 0.9, Content: "x", MatchType: sdk.MatchTypeKeyword},
}}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Contains(t, out.String(), `"match_type":1`)
}
@@ -60,7 +60,7 @@ func TestRunSearch_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: []*sdk.SearchResult{{Score: 0.9, Content: "x"}}}
opts := &ChunksOptions{Query: "q", KBID: "kb1", Limit: 1}
require.NoError(t, runChunks(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runChunks(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), "["), "expected bare JSON array, got: %q", got)
assert.NotContains(t, got, `"ok":`)
@@ -70,7 +70,7 @@ func TestRunSearch_JSONOutput(t *testing.T) {
func TestRunSearch_EmptyResults(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: nil}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, nil, svc))
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "(no results)")
}
@@ -86,7 +86,7 @@ func TestRunSearch_LimitHardCap(t *testing.T) {
{Score: 0, Content: "enrichment parent"}, // server-padded
{Score: 0, Content: "enrichment nearby"}, // server-padded
}}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1", Limit: 3}, nil, svc))
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1", Limit: 3}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
assert.Contains(t, got, "3 result(s)")
assert.NotContains(t, got, "enrichment parent")
@@ -95,7 +95,7 @@ func TestRunSearch_LimitHardCap(t *testing.T) {
func TestRunSearch_BothChannelsDisabled(t *testing.T) {
iostreams.SetForTest(t)
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1", NoVector: true, NoKeyword: true}, nil, &fakeChunksSvc{})
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1", NoVector: true, NoKeyword: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeChunksSvc{})
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
@@ -103,7 +103,7 @@ func TestRunSearch_BothChannelsDisabled(t *testing.T) {
func TestRunSearch_ServiceError_Transport(t *testing.T) {
iostreams.SetForTest(t)
svc := &fakeChunksSvc{err: assert.AnError}
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, nil, svc)
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -114,7 +114,7 @@ func TestRunSearch_ServiceError_Transport(t *testing.T) {
func TestRunSearch_ServiceError_HTTPNotFound(t *testing.T) {
iostreams.SetForTest(t)
svc := &fakeChunksSvc{err: errors.New("HTTP error 404: knowledge base not found")}
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "missing"}, nil, svc)
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "missing"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -128,7 +128,7 @@ func TestIndent(t *testing.T) {
func TestRunSearch_NilService(t *testing.T) {
iostreams.SetForTest(t)
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, nil, nil)
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "server.error")
}
@@ -164,7 +164,7 @@ func TestRunSearch_NoVectorPassedThrough(t *testing.T) {
svc := &capturingChunksSvc{capture: func(p *sdk.SearchParams) { got = p }}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{
Query: "q", KBID: "kb1", NoVector: true,
}, nil, svc))
}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
require.NotNil(t, got)
assert.True(t, got.DisableVectorMatch)
assert.False(t, got.DisableKeywordsMatch)
@@ -176,7 +176,7 @@ func TestRunSearch_NoKeywordPassedThrough(t *testing.T) {
svc := &capturingChunksSvc{capture: func(p *sdk.SearchParams) { got = p }}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{
Query: "q", KBID: "kb1", NoKeyword: true,
}, nil, svc))
}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
require.NotNil(t, got)
assert.True(t, got.DisableKeywordsMatch)
assert.False(t, got.DisableVectorMatch)
+12 -11
View File
@@ -21,10 +21,10 @@ import (
// single page even at conservative sizes. Server caps page_size at 1000.
const docsPageSize = 200
// docsMaxPageSize bounds the --page-size flag, matching session/doc list canon.
// docsMaxPageSize bounds the --page-size flag, matching the session/doc list cap.
const docsMaxPageSize = 1000
// docsFields enumerates the fields surfaced for `--json` discovery on
// docsFields enumerates the fields surfaced for `--format json` discovery on
// `search docs`. Mirrors sdk.Knowledge json tags.
var docsFields = []string{
"id", "tenant_id", "knowledge_base_id", "tag_id", "type", "title",
@@ -92,10 +92,11 @@ reached or the KB is exhausted (matching v0.4 behavior). Pass
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
@@ -105,19 +106,19 @@ reached or the KB is exhausted (matching v0.4 behavior). Pass
return err
}
opts.KBID = kbID
return runDocsSearch(c.Context(), opts, jopts, cli)
return runDocsSearch(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name (required)")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return")
cmd.Flags().IntVar(&opts.PageSize, "page-size", docsPageSize, "Items per server batch (1..1000)")
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", true, "Walk every server page until exhausted or --limit hit")
cmdutil.AddJSONFlags(cmd, docsFields)
cmdutil.AddFormatFlag(cmd, docsFields...)
_ = cmd.MarkFlagRequired("kb")
return cmd
}
func runDocsSearch(ctx context.Context, opts *DocsSearchOptions, jopts *cmdutil.JSONOptions, svc DocsSearchService) error {
func runDocsSearch(ctx context.Context, opts *DocsSearchOptions, fopts *cmdutil.FormatOptions, svc DocsSearchService) error {
if opts.PageSize < 1 || opts.PageSize > docsMaxPageSize {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("--page-size must be in 1..%d, got %d", docsMaxPageSize, opts.PageSize))
@@ -129,8 +130,8 @@ func runDocsSearch(ctx context.Context, opts *DocsSearchOptions, jopts *cmdutil.
// The server applies the keyword filter pre-pagination, so every item
// returned is already a match - no client-side filter needed.
// --all-pages=true (default) walks every server page; --all-pages=false
// stops after the first page. The server returns total; stop when
// page*pageSize >= total.
// stops after the first page. Termination counts records actually
// received so server-capped page_size doesn't truncate.
for page := 1; ; page++ {
items, total, err := svc.ListKnowledgeWithFilter(ctx, opts.KBID, page, opts.PageSize, filter)
if err != nil {
@@ -145,18 +146,18 @@ func runDocsSearch(ctx context.Context, opts *DocsSearchOptions, jopts *cmdutil.
if !opts.AllPages {
break
}
if int64(page*opts.PageSize) >= total || len(items) == 0 {
if int64(len(matches)) >= total || len(items) == 0 {
break
}
}
done:
sortKnowledgeByRecency(matches)
if jopts.Enabled() {
if fopts.WantsJSON() {
if matches == nil {
matches = []sdk.Knowledge{}
}
return jopts.Emit(iostreams.IO.Out, matches)
return fopts.Emit(iostreams.IO.Out, matches)
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
+11 -11
View File
@@ -48,7 +48,7 @@ func TestDocsSearch_Substring(t *testing.T) {
},
total: 2,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "q3", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, nil, svc))
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "q3", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Equal(t, "q3", svc.lastFilter.Keyword, "query must be threaded as filter.Keyword")
got := out.String()
assert.Contains(t, got, "d1")
@@ -62,7 +62,7 @@ func TestDocsSearch_MatchesFileName(t *testing.T) {
pages: map[int][]sdk.Knowledge{1: {{ID: "d1", Title: "Untitled", FileName: "report.pdf"}}},
total: 1,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "report", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, nil, svc))
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "report", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "d1")
}
@@ -81,7 +81,7 @@ func TestDocsSearch_PaginatesUntilTotal(t *testing.T) {
pages: map[int][]sdk.Knowledge{1: page1, 2: page2},
total: int64(docsPageSize) + 1,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: docsPageSize + 1, PageSize: docsPageSize, AllPages: true}, nil, svc))
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: docsPageSize + 1, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "found")
assert.Equal(t, []int{1, 2}, svc.calls, "must page past the first batch when more items reported")
}
@@ -93,7 +93,7 @@ func TestDocsSearch_StopsAtLimit(t *testing.T) {
page1[i] = sdk.Knowledge{ID: "match", Title: "needle"}
}
svc := &fakeDocsSearchSvc{pages: map[int][]sdk.Knowledge{1: page1}, total: 1000}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: 3, PageSize: docsPageSize, AllPages: true}, nil, svc))
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: 3, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
// Must not request page 2 because limit was hit mid-page.
assert.Equal(t, []int{1}, svc.calls)
}
@@ -104,7 +104,7 @@ func TestDocsSearch_JSON(t *testing.T) {
pages: map[int][]sdk.Knowledge{1: {{ID: "d1", Title: "match"}}},
total: 1,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "match", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "match", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), "["), "expected bare JSON array, got: %q", got)
assert.Contains(t, got, `"id":"d1"`)
@@ -114,7 +114,7 @@ func TestDocsSearch_JSON(t *testing.T) {
func TestDocsSearch_NetworkError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{err: errors.New("HTTP error 404: kb not found")}
err := runDocsSearch(context.Background(), &DocsSearchOptions{Query: "x", KBID: "missing", Limit: 20, PageSize: docsPageSize, AllPages: true}, nil, svc)
err := runDocsSearch(context.Background(), &DocsSearchOptions{Query: "x", KBID: "missing", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -136,7 +136,7 @@ func TestSearchDocs_AllPagesFlag_DefaultsTrue_WalksAllPages(t *testing.T) {
total: 3,
}
opts := &DocsSearchOptions{Query: "needle", KBID: "kb_abc", Limit: 100, PageSize: 2, AllPages: true}
require.NoError(t, runDocsSearch(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runDocsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.GreaterOrEqual(t, len(svc.calls), 2, "must walk multi pages by default")
}
@@ -150,7 +150,7 @@ func TestSearchDocs_AllPagesFalse_StopsAtFirstPage(t *testing.T) {
total: 100,
}
opts := &DocsSearchOptions{Query: "needle", KBID: "kb_abc", Limit: 100, PageSize: 2, AllPages: false}
require.NoError(t, runDocsSearch(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runDocsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Len(t, svc.calls, 1, "must stop at first page when --all-pages=false")
}
@@ -161,7 +161,7 @@ func TestSearchDocs_AllPagesFalse_StopsAtFirstPage(t *testing.T) {
func TestSearchDocs_KeywordPassedToFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{pages: map[int][]sdk.Knowledge{1: {{ID: "d1"}}}, total: 1}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "my-query", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, nil, svc))
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "my-query", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Equal(t, "my-query", svc.lastFilter.Keyword, "Query must be threaded as filter.Keyword on ListKnowledgeWithFilter")
// Other filter fields must be empty - search docs only forwards the keyword.
assert.Empty(t, svc.lastFilter.ParseStatus)
@@ -171,11 +171,11 @@ func TestSearchDocs_KeywordPassedToFilter(t *testing.T) {
}
// TestSearchDocs_PageSizeBound asserts the 1..1000 range guard mirrors the
// session/doc list canon. Out-of-range values must produce
// session/doc list cap. Out-of-range values must produce
// input.invalid_argument and never reach the SDK.
func TestSearchDocs_PageSizeBound(t *testing.T) {
for _, ps := range []int{0, -1, 1001} {
err := runDocsSearch(context.Background(), &DocsSearchOptions{Query: "t", KBID: "k", Limit: 50, PageSize: ps}, &cmdutil.JSONOptions{}, &fakeDocsSearchSvc{})
err := runDocsSearch(context.Background(), &DocsSearchOptions{Query: "t", KBID: "k", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeDocsSearchSvc{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
+9 -8
View File
@@ -15,7 +15,7 @@ import (
sdk "github.com/Tencent/WeKnora/client"
)
// kbSearchFields enumerates the fields surfaced for `--json` discovery on
// kbSearchFields enumerates the fields surfaced for `--format json` discovery on
// `search kb`. Subset of KnowledgeBase suitable for list/filter results.
var kbSearchFields = []string{
"id", "name", "type", "description",
@@ -54,7 +54,7 @@ usually the closest hit) for deterministic output.
This is name-discovery only - for searching *inside* a knowledge base's
content, use ` + "`weknora search chunks`" + `.`,
Example: ` weknora search kb "marketing"
weknora search kb "team" --limit 5 --json`,
weknora search kb "team" --limit 5 --format json`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
@@ -64,23 +64,24 @@ content, use ` + "`weknora search chunks`" + `.`,
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runKBSearch(c.Context(), opts, jopts, cli)
return runKBSearch(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return")
cmdutil.AddJSONFlags(cmd, kbSearchFields)
cmdutil.AddFormatFlag(cmd, kbSearchFields...)
return cmd
}
func runKBSearch(ctx context.Context, opts *KBSearchOptions, jopts *cmdutil.JSONOptions, svc KBSearchService) error {
func runKBSearch(ctx context.Context, opts *KBSearchOptions, fopts *cmdutil.FormatOptions, svc KBSearchService) error {
items, err := svc.ListKnowledgeBases(ctx)
if err != nil {
return cmdutil.WrapHTTP(err, "list knowledge bases")
@@ -90,11 +91,11 @@ func runKBSearch(ctx context.Context, opts *KBSearchOptions, jopts *cmdutil.JSON
matches = matches[:opts.Limit]
}
if jopts.Enabled() {
if fopts.WantsJSON() {
if matches == nil {
matches = []sdk.KnowledgeBase{}
}
return jopts.Emit(iostreams.IO.Out, matches)
return fopts.Emit(iostreams.IO.Out, matches)
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
+8 -8
View File
@@ -30,7 +30,7 @@ func TestKBSearch_Substring(t *testing.T) {
{ID: "kb2", Name: "Engineering Docs", KnowledgeCount: 50},
{ID: "kb3", Name: "Marketing Q4 Plan", KnowledgeCount: 5},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, nil, svc))
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
assert.Contains(t, got, "kb1")
assert.Contains(t, got, "kb3")
@@ -42,7 +42,7 @@ func TestKBSearch_CaseInsensitive(t *testing.T) {
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "kb1", Name: "ENGINEERING"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "engineering", Limit: 20}, nil, svc))
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "engineering", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "kb1")
}
@@ -51,7 +51,7 @@ func TestKBSearch_MatchesDescription(t *testing.T) {
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "kb1", Name: "Engineering", Description: "all marketing docs are here"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, nil, svc))
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "kb1")
}
@@ -62,7 +62,7 @@ func TestKBSearch_SortByNameLength(t *testing.T) {
{ID: "kb_short", Name: "marketing"},
{ID: "kb_mid", Name: "marketing 2024"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, nil, svc))
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
// Order: shortest name first.
iShort := strings.Index(got, "kb_short")
@@ -78,7 +78,7 @@ func TestKBSearch_LimitHardCap(t *testing.T) {
{ID: "a", Name: "match-a"}, {ID: "b", Name: "match-b"},
{ID: "c", Name: "match-c"}, {ID: "d", Name: "match-d"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "match", Limit: 2}, nil, svc))
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "match", Limit: 2}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
count := 0
for _, id := range []string{"a", "b", "c", "d"} {
@@ -92,14 +92,14 @@ func TestKBSearch_LimitHardCap(t *testing.T) {
func TestKBSearch_NoMatches(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{{Name: "foo"}}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "bar", Limit: 20}, nil, svc))
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "bar", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "(no matches)")
}
func TestKBSearch_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{{ID: "kb1", Name: "marketing"}}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), "["), "expected bare JSON array, got: %q", got)
@@ -110,7 +110,7 @@ func TestKBSearch_JSON(t *testing.T) {
func TestKBSearch_NetworkError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{err: errors.New("HTTP error 401: unauthenticated")}
err := runKBSearch(context.Background(), &KBSearchOptions{Query: "x", Limit: 20}, nil, svc)
err := runKBSearch(context.Background(), &KBSearchOptions{Query: "x", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
+15 -10
View File
@@ -21,10 +21,10 @@ import (
// filter client-side. Tunable via --page-size in 1..1000.
const sessionsPageSize = 200
// sessionsMaxPageSize bounds the --page-size flag, matching session/doc list canon.
// sessionsMaxPageSize bounds the --page-size flag, matching the session/doc list cap.
const sessionsMaxPageSize = 1000
// sessionsSearchFields enumerates the fields surfaced for `--json` discovery
// sessionsSearchFields enumerates the fields surfaced for `--format json` discovery
// on `search sessions`. Mirrors sdk.Session json tags.
var sessionsSearchFields = []string{
"id", "tenant_id", "title", "description", "created_at", "updated_at",
@@ -64,7 +64,7 @@ By default, --all-pages=true walks every server page until --limit is
reached or the tenant's sessions are exhausted (matching v0.4 behavior).
Pass --all-pages=false to stop after one page.`,
Example: ` weknora search sessions "onboarding"
weknora search sessions "Q3 review" --limit 3 --json
weknora search sessions "Q3 review" --limit 3 --format json
weknora search sessions "Q3 review" --all-pages=false`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
@@ -75,31 +75,35 @@ Pass --all-pages=false to stop after one page.`,
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runSessionsSearch(c.Context(), opts, jopts, cli)
return runSessionsSearch(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return")
cmd.Flags().IntVar(&opts.PageSize, "page-size", sessionsPageSize, "Items per server batch (1..1000)")
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", true, "Walk every server page until exhausted or --limit hit")
cmdutil.AddJSONFlags(cmd, sessionsSearchFields)
cmdutil.AddFormatFlag(cmd, sessionsSearchFields...)
return cmd
}
func runSessionsSearch(ctx context.Context, opts *SessionsSearchOptions, jopts *cmdutil.JSONOptions, svc SessionsSearchService) error {
func runSessionsSearch(ctx context.Context, opts *SessionsSearchOptions, fopts *cmdutil.FormatOptions, svc SessionsSearchService) error {
if opts.PageSize < 1 || opts.PageSize > sessionsMaxPageSize {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("--page-size must be in 1..%d, got %d", sessionsMaxPageSize, opts.PageSize))
}
needle := strings.ToLower(opts.Query)
var matches []sdk.Session
var received int // count of server-returned items so far (separate from
// matches, which is client-filtered). Used for termination so a
// server-capped page_size doesn't cause early break.
// --all-pages=true (default) walks every server page; --all-pages=false
// stops after the first page.
@@ -108,6 +112,7 @@ func runSessionsSearch(ctx context.Context, opts *SessionsSearchOptions, jopts *
if err != nil {
return cmdutil.WrapHTTP(err, "list sessions")
}
received += len(items)
for _, s := range items {
if matchSession(s, needle) {
matches = append(matches, s)
@@ -119,18 +124,18 @@ func runSessionsSearch(ctx context.Context, opts *SessionsSearchOptions, jopts *
if !opts.AllPages {
break
}
if page*opts.PageSize >= total || len(items) == 0 {
if received >= total || len(items) == 0 {
break
}
}
done:
sortSessionsByRecency(matches)
if jopts.Enabled() {
if fopts.WantsJSON() {
if matches == nil {
matches = []sdk.Session{}
}
return jopts.Emit(iostreams.IO.Out, matches)
return fopts.Emit(iostreams.IO.Out, matches)
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
+9 -9
View File
@@ -39,7 +39,7 @@ func TestSessionsSearch_TitleAndDescription(t *testing.T) {
}},
total: 3,
}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "design", Limit: 20, PageSize: sessionsPageSize, AllPages: true}, nil, svc))
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "design", Limit: 20, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
assert.Contains(t, got, "s1")
assert.Contains(t, got, "s2")
@@ -52,7 +52,7 @@ func TestSessionsSearch_NoMatches(t *testing.T) {
pages: map[int][]sdk.Session{1: {{Title: "foo"}}},
total: 1,
}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "missing", Limit: 20, PageSize: sessionsPageSize, AllPages: true}, nil, svc))
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "missing", Limit: 20, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "(no matches)")
}
@@ -63,14 +63,14 @@ func TestSessionsSearch_PaginatesAndStopsAtLimit(t *testing.T) {
page1[i] = sdk.Session{ID: "m", Title: "needle"}
}
svc := &fakeSessionsSearchSvc{pages: map[int][]sdk.Session{1: page1}, total: 1000}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "needle", Limit: 5, PageSize: sessionsPageSize, AllPages: true}, nil, svc))
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "needle", Limit: 5, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Equal(t, []int{1}, svc.calls, "stops paging when limit reached")
}
func TestSessionsSearch_NetworkError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSessionsSearchSvc{err: errors.New("HTTP error 500: internal")}
err := runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "x", Limit: 20, PageSize: sessionsPageSize, AllPages: true}, nil, svc)
err := runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "x", Limit: 20, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -90,7 +90,7 @@ func TestSessionsSearch_RendersFuzzyTime(t *testing.T) {
}},
total: 1,
}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "needle", Limit: 10, PageSize: sessionsPageSize, AllPages: true}, nil, svc))
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "needle", Limit: 10, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
assert.Contains(t, body, "hour", "must render relative time (e.g. 'about 2 hours ago'), not raw RFC3339")
assert.NotContains(t, body, "T0", "raw RFC3339 has 'T' between date and time; fuzzyTime output should not")
@@ -111,7 +111,7 @@ func TestSearchSessions_AllPagesFlag_DefaultsTrue_WalksAllPages(t *testing.T) {
total: 3,
}
opts := &SessionsSearchOptions{Query: "needle", Limit: 100, PageSize: 2, AllPages: true}
require.NoError(t, runSessionsSearch(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runSessionsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.GreaterOrEqual(t, len(svc.calls), 2, "must walk multi pages by default")
}
@@ -125,16 +125,16 @@ func TestSearchSessions_AllPagesFalse_StopsAtFirstPage(t *testing.T) {
total: 100,
}
opts := &SessionsSearchOptions{Query: "needle", Limit: 100, PageSize: 2, AllPages: false}
require.NoError(t, runSessionsSearch(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runSessionsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Len(t, svc.calls, 1, "must stop at first page when --all-pages=false")
}
// TestSearchSessions_PageSizeBound asserts the 1..1000 range guard mirrors
// the session/doc list canon. Out-of-range values must produce
// the session/doc list cap. Out-of-range values must produce
// input.invalid_argument and never reach the SDK.
func TestSearchSessions_PageSizeBound(t *testing.T) {
for _, ps := range []int{0, -1, 1001} {
err := runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "t", Limit: 50, PageSize: ps}, &cmdutil.JSONOptions{}, &fakeSessionsSearchSvc{})
err := runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "t", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeSessionsSearchSvc{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
+98 -21
View File
@@ -3,6 +3,7 @@ package sessioncmd
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
@@ -11,8 +12,9 @@ import (
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// sessionDeleteFields enumerates the fields surfaced for `--json` discovery on
// `session delete`. Tracks the small result struct.
// sessionDeleteFields enumerates the fields surfaced for `--format json`
// discovery on `session delete`. Tracks the single-id result struct;
// multi-id mode emits MultiDeleteResult (ok/failed).
var sessionDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {
@@ -24,50 +26,87 @@ type DeleteService interface {
DeleteSession(ctx context.Context, id string) error
}
// deleteResult is the typed payload emitted under data on success.
// deleteResult is the typed payload emitted under data on single-id success.
type deleteResult struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
}
// NewCmdDelete builds `weknora session delete`. Destructive write gated
// by -y/--yes (exit-10 protocol in scripted / --json invocations).
// MultiDeleteResult is the payload for multi-id deletes.
// ok: ids successfully deleted; failed: ids that could not be deleted.
type MultiDeleteResult struct {
OK []string `json:"ok"`
Failed []FailedItem `json:"failed,omitempty"`
}
// FailedItem records an id that failed to delete along with its error message.
type FailedItem struct {
ID string `json:"id"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
}
// NewCmdDelete builds `weknora session delete`. Single-id keeps the simpler
// code path; multi-id uses keep-going semantics (one -y confirms all,
// failures collected, exit 1 if any fail).
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.
Use: "delete <session-id> [<session-id>...]",
Short: "Delete one or more chat sessions",
Long: `Permanently delete one or more chat sessions and their 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).
Prompts for confirmation by default when stdout is a TTY and JSON output is
not set. Pass -y/--yes (global flag) to skip the prompt (required in agent
/ CI / piped contexts).
Single-id: one confirm prompt, exit 0/1.
Multi-id:
• Default keep-going: failed deletes do NOT stop the run; failures collected.
• One -y/--yes confirms all sessions.
• Exit 0 if all succeed; exit 1 if any failed.
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead.`,
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),
Example: ` weknora session delete s_abc # interactive confirm
weknora session delete s_abc -y # no prompt
weknora session delete s_abc -y --format json # bare {id, deleted:true} JSON
weknora session delete s_a s_b s_c -y # delete 3, keep-going`,
Args: cobra.MinimumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Yes, _ = c.Flags().GetBool("yes")
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runDelete(c.Context(), opts, jopts, cli, f.Prompter(), args[0])
// Single-id uses the simpler code path (bare {id, deleted}).
if len(args) == 1 {
return runDelete(c.Context(), opts, fopts, cli, f.Prompter(), args[0])
}
res, runErr := runMultiDelete(c.Context(), opts, fopts, cli, f.Prompter(), args)
// Only emit when the operation actually ran. Pre-flight errors
// (e.g. confirmation_required) must leave stdout empty per the
// wire contract in README.md.
if len(res.OK) > 0 || len(res.Failed) > 0 {
if emitErr := emitMultiDelete(res, fopts, iostreams.IO.Out); emitErr != nil {
return emitErr
}
}
return runErr
},
}
cmdutil.AddJSONFlags(cmd, sessionDeleteFields)
cmdutil.AddFormatFlag(cmd, sessionDeleteFields...)
return cmd
}
func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOptions, svc DeleteService, p prompt.Prompter, id string) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "session", id); err != nil {
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter, id string) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "session", id); err != nil {
return err
}
@@ -75,9 +114,47 @@ func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOpti
return cmdutil.WrapHTTP(err, "delete session %s", id)
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true})
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true})
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted session %s\n", id)
return nil
}
// runMultiDelete iterates ids sequentially, keep-going on error: a single
// failure does not abort the run, so the caller sees the full outcome.
func runMultiDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter, ids []string) (*MultiDeleteResult, error) {
if err := cmdutil.ConfirmDestructiveBatch(p, opts.Yes, fopts.WantsJSON(), "session", len(ids)); err != nil {
return &MultiDeleteResult{}, err
}
res := &MultiDeleteResult{}
for _, id := range ids {
if err := svc.DeleteSession(ctx, id); err != nil {
res.Failed = append(res.Failed, FailedItem{ID: id, Message: err.Error()})
continue
}
res.OK = append(res.OK, id)
}
if len(res.Failed) > 0 {
return res, cmdutil.NewError(cmdutil.CodeOperationFailed, fmt.Sprintf("%d/%d delete(s) failed", len(res.Failed), len(ids)))
}
return res, nil
}
// emitMultiDelete renders per --format. Mirrors doc delete emitMultiDelete.
func emitMultiDelete(res *MultiDeleteResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
switch fopts.Mode {
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
return fopts.Emit(w, res)
case cmdutil.FormatText, "":
for _, id := range res.OK {
fmt.Fprintf(w, "OK %s\n", id)
}
for _, f := range res.Failed {
fmt.Fprintf(w, "FAIL %s: %s\n", f.ID, f.Message)
}
return nil
default:
return fmt.Errorf("unsupported --format %q for session delete", fopts.Mode)
}
}
+65 -5
View File
@@ -30,7 +30,7 @@ func TestDelete_WithYes(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{}
require.NoError(t, runDelete(context.Background(), &DeleteOptions{Yes: true}, nil, svc, p, "s_abc"))
require.NoError(t, runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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")
@@ -40,7 +40,7 @@ func TestDelete_WithYes(t *testing.T) {
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}, nil, svc, &testutil.ConfirmPrompter{}, "s_missing")
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "s_missing")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -50,7 +50,7 @@ func TestDelete_NotFound(t *testing.T) {
func TestDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, &testutil.ConfirmPrompter{}, "s_x")
err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "s_x")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -63,7 +63,7 @@ func TestDelete_TTY_ConfirmYes(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: true}
require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "s_yes"))
require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "s_yes"))
assert.True(t, p.Asked)
assert.True(t, svc.called)
}
@@ -72,7 +72,7 @@ func TestDelete_TTY_ConfirmNo(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: false}
err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "s_no")
err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "s_no")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -80,3 +80,63 @@ func TestDelete_TTY_ConfirmNo(t *testing.T) {
assert.False(t, svc.called)
assert.Contains(t, errBuf.String(), "Aborted")
}
// ---------------------------------------------------------------------------
// Multi-id (keep-going semantics)
// ---------------------------------------------------------------------------
// fakeMultiDeleteSvc records every id deleted and can fail-on selected ids.
type fakeMultiDeleteSvc struct {
deleted []string
failOn map[string]error
}
func (f *fakeMultiDeleteSvc) DeleteSession(_ context.Context, id string) error {
if e, ok := f.failOn[id]; ok {
return e
}
f.deleted = append(f.deleted, id)
return nil
}
func TestMultiDelete_AllSucceed(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeMultiDeleteSvc{}
res, err := runMultiDelete(context.Background(),
&DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
[]string{"s_a", "s_b", "s_c"})
require.NoError(t, err)
assert.Equal(t, []string{"s_a", "s_b", "s_c"}, res.OK)
assert.Empty(t, res.Failed)
assert.Equal(t, []string{"s_a", "s_b", "s_c"}, svc.deleted)
}
func TestMultiDelete_PartialFailure_KeepsGoing(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeMultiDeleteSvc{failOn: map[string]error{"s_b": errors.New("HTTP error 404: not found")}}
res, err := runMultiDelete(context.Background(),
&DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
[]string{"s_a", "s_b", "s_c"})
require.Error(t, err, "any-failed must surface CodeOperationFailed")
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
assert.Equal(t, []string{"s_a", "s_c"}, res.OK, "keep-going: s_c was still attempted after s_b failed")
assert.Len(t, res.Failed, 1)
assert.Equal(t, "s_b", res.Failed[0].ID)
}
func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeMultiDeleteSvc{}
res, err := runMultiDelete(context.Background(),
&DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
[]string{"s_a", "s_b"})
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.Empty(t, res.OK)
assert.Empty(t, svc.deleted, "non-TTY without -y must not call DeleteSession")
}
+9 -8
View File
@@ -21,7 +21,7 @@ const (
maxPageSize = 1000
)
// sessionListFields enumerates the fields surfaced for `--json` discovery on
// sessionListFields enumerates the fields surfaced for `--format json` discovery on
// `session list`. Mirrors sdk.Session json tags.
var sessionListFields = []string{
"id", "tenant_id", "title", "description", "created_at", "updated_at",
@@ -51,26 +51,27 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
Short: "List chat sessions for the active context",
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runList(c.Context(), opts, jopts, cli)
return runList(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per server batch (1..1000)")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (0 = no cap, 1..10000 = explicit)")
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")
cmd.Flags().StringVar(&opts.Since, "since", "", "Only show sessions updated within `duration` (e.g. 7d, 24h, 30m)")
cmdutil.AddJSONFlags(cmd, sessionListFields)
cmdutil.AddFormatFlag(cmd, sessionListFields...)
return cmd
}
func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, svc ListService) error {
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
if opts.PageSize < 1 || opts.PageSize > maxPageSize {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
@@ -105,7 +106,7 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
accum = accum[:opts.Limit]
break
}
if page*opts.PageSize >= total || len(chunk) == 0 {
if len(accum) >= total || len(chunk) == 0 {
break
}
}
@@ -139,8 +140,8 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
items = items[:opts.Limit]
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, items)
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, items)
}
if len(items) == 0 {
+16 -16
View File
@@ -34,7 +34,7 @@ func (f *fakeListService) GetSessionsByTenant(_ context.Context, page, pageSize
func TestList_Empty(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListService{items: nil, total: 0}
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, nil, svc))
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "no sessions")
}
@@ -47,7 +47,7 @@ func TestList_Table(t *testing.T) {
},
total: 2,
}
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, nil, svc))
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
assert.Contains(t, got, "s_1")
assert.Contains(t, got, "Design review")
@@ -64,7 +64,7 @@ func TestList_JSON_BareArray(t *testing.T) {
},
total: 47,
}
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 10}, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 10}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
// CLI always asks for page 1 of size --page-size; pagination is server-internal.
assert.Equal(t, 1, svc.gotPage)
@@ -81,7 +81,7 @@ func TestList_JSON_BareArray(t *testing.T) {
func TestList_NilItems_RendersAsBareEmptyArray(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListService{items: nil, total: 0}
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, &cmdutil.JSONOptions{}, svc))
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, "[]", strings.TrimSpace(out.String()))
}
@@ -96,7 +96,7 @@ func TestList_BadPagination(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := runList(context.Background(), &ListOptions{PageSize: tc.size}, nil, &fakeListService{})
err := runList(context.Background(), &ListOptions{PageSize: tc.size}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListService{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -108,7 +108,7 @@ func TestList_BadPagination(t *testing.T) {
func TestList_NetworkError_TypedCode(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListService{err: errors.New("HTTP error 401: unauthenticated")}
err := runList(context.Background(), &ListOptions{PageSize: 30}, nil, svc)
err := runList(context.Background(), &ListOptions{PageSize: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -119,7 +119,7 @@ func TestList_NetworkError_TypedCode(t *testing.T) {
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{PageSize: 30}, nil, svc))
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "s_zh")
}
@@ -132,7 +132,7 @@ func TestList_SinceFilter_DropsOldSessions(t *testing.T) {
{ID: "yesterday", Title: "yday", UpdatedAt: now.Add(-23 * time.Hour).Format(time.RFC3339)},
}
require.NoError(t, runList(context.Background(),
&ListOptions{PageSize: 30, Since: "7d"}, nil,
&ListOptions{PageSize: 30, Since: "7d"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText},
&fakeListService{items: items, total: 3}))
got := out.String()
assert.Contains(t, got, "recent")
@@ -146,7 +146,7 @@ func TestList_SinceFilter_ParseDuration_Variants(t *testing.T) {
t.Run(v, func(t *testing.T) {
_, _ = iostreams.SetForTest(t)
require.NoError(t, runList(context.Background(),
&ListOptions{PageSize: 30, Since: v}, nil,
&ListOptions{PageSize: 30, Since: v}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText},
&fakeListService{items: []sdk.Session{}, total: 0}),
"--since=%q should parse", v)
})
@@ -156,7 +156,7 @@ func TestList_SinceFilter_ParseDuration_Variants(t *testing.T) {
func TestList_SinceFilter_RejectsInvalidDuration(t *testing.T) {
_, _ = iostreams.SetForTest(t)
err := runList(context.Background(),
&ListOptions{PageSize: 30, Since: "bogus"}, nil,
&ListOptions{PageSize: 30, Since: "bogus"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText},
&fakeListService{items: []sdk.Session{}, total: 0})
require.Error(t, err)
var typed *cmdutil.Error
@@ -168,7 +168,7 @@ func TestList_SinceFilter_RejectsNonPositive(t *testing.T) {
_, _ = iostreams.SetForTest(t)
for _, v := range []string{"0d", "0h", "-1h"} {
err := runList(context.Background(),
&ListOptions{PageSize: 30, Since: v}, nil,
&ListOptions{PageSize: 30, Since: v}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText},
&fakeListService{items: []sdk.Session{}, total: 0})
require.Error(t, err, "--since=%q should reject", v)
var typed *cmdutil.Error
@@ -217,7 +217,7 @@ func TestList_Limit_LessThanPageSize_SlicesToLimit(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListService{items: makeSessions(20), total: 20}
require.NoError(t, runList(context.Background(),
&ListOptions{PageSize: 20, Limit: 5}, &cmdutil.JSONOptions{}, svc))
&ListOptions{PageSize: 20, Limit: 5}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
got := strings.Count(out.String(), `"id":"s_`)
assert.Equal(t, 5, got, "--limit 5 must slice 20 items down to 5")
}
@@ -226,7 +226,7 @@ func TestList_Limit_GreaterThanPageSize_NoCap(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListService{items: makeSessions(10), total: 10}
require.NoError(t, runList(context.Background(),
&ListOptions{PageSize: 10, Limit: 50}, &cmdutil.JSONOptions{}, svc))
&ListOptions{PageSize: 10, Limit: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
got := strings.Count(out.String(), `"id":"s_`)
assert.Equal(t, 10, got)
}
@@ -234,7 +234,7 @@ func TestList_Limit_GreaterThanPageSize_NoCap(t *testing.T) {
func TestList_Limit_Negative_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
err := runList(context.Background(),
&ListOptions{PageSize: 30, Limit: -1}, nil,
&ListOptions{PageSize: 30, Limit: -1}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText},
&fakeListService{})
require.Error(t, err)
var typed *cmdutil.Error
@@ -246,7 +246,7 @@ func TestList_AllPages_WalksAllServerPages(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &pagedSessionSvc{all: makeSessions(45)}
require.NoError(t, runList(context.Background(),
&ListOptions{PageSize: 20, AllPages: true}, &cmdutil.JSONOptions{}, svc))
&ListOptions{PageSize: 20, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, []int{1, 2, 3}, svc.calls)
got := strings.Count(out.String(), `"id":"s_`)
assert.Equal(t, 45, got)
@@ -256,7 +256,7 @@ func TestList_AllPages_WithLimit_StopsAtLimit(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &pagedSessionSvc{all: makeSessions(200)}
require.NoError(t, runList(context.Background(),
&ListOptions{PageSize: 20, AllPages: true, Limit: 50}, &cmdutil.JSONOptions{}, svc))
&ListOptions{PageSize: 20, AllPages: true, Limit: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
got := strings.Count(out.String(), `"id":"s_`)
assert.Equal(t, 50, got)
assert.LessOrEqual(t, len(svc.calls), 3, "must not fetch beyond what fills --limit")
+10 -9
View File
@@ -17,7 +17,7 @@ const (
maxFullLimit = 1000
)
// sessionViewFields enumerates the fields surfaced for `--json` discovery on
// sessionViewFields enumerates the fields surfaced for `--format json` discovery on
// `session view`. Mirrors sdk.Session json tags; adds the synthesized
// `messages` projection surfaced by `--full`.
var sessionViewFields = []string{
@@ -53,7 +53,7 @@ type ViewService interface {
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
opts := &ViewOptions{Limit: defaultFullLimit}
cmd := &cobra.Command{
Use: "view <id>",
Use: "view <session-id>",
Short: "Show a chat session by ID",
Long: `Show a chat session.
@@ -65,24 +65,25 @@ Pass --full to also load the chat history (LoadMessages SDK call). Use
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.LimitSet = c.Flags().Changed("limit")
jopts, err := cmdutil.CheckJSONFlags(c)
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runView(c.Context(), opts, jopts, cli, args[0])
return runView(c.Context(), opts, fopts, cli, args[0])
},
}
cmd.Flags().BoolVar(&opts.Full, "full", false, "Also load chat history via LoadMessages")
cmd.Flags().IntVar(&opts.Limit, "limit", defaultFullLimit, "Max messages to load when --full is set (1..1000)")
cmdutil.AddJSONFlags(cmd, sessionViewFields)
cmdutil.AddFormatFlag(cmd, sessionViewFields...)
return cmd
}
func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions, svc ViewService, id string) error {
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService, id string) error {
if !opts.Full && opts.LimitSet {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
@@ -114,9 +115,9 @@ func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions,
}
}
if jopts.Enabled() {
if fopts.WantsJSON() {
if !opts.Full {
return jopts.Emit(iostreams.IO.Out, s)
return fopts.Emit(iostreams.IO.Out, s)
}
// Project session + messages into a single bare object. Use the
// SDK json tags via an embedded *Session so existing keys stay
@@ -125,7 +126,7 @@ func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions,
*sdk.Session
Messages []sdk.Message `json:"messages"`
}{Session: s, Messages: msgs}
return jopts.Emit(iostreams.IO.Out, payload)
return fopts.Emit(iostreams.IO.Out, payload)
}
w := iostreams.IO.Out
+11 -11
View File
@@ -50,7 +50,7 @@ func TestView_Human(t *testing.T) {
CreatedAt: "2026-05-10T09:00:00Z",
UpdatedAt: "2026-05-12T14:00:00Z",
}}
require.NoError(t, runView(context.Background(), &ViewOptions{}, nil, svc, "s_abc"))
require.NoError(t, runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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)
@@ -61,7 +61,7 @@ func TestView_Human(t *testing.T) {
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{}, &cmdutil.JSONOptions{}, svc, "s_abc"))
require.NoError(t, runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "s_abc"))
body := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(body), `{"id":"s_abc"`), "bare object expected; got %q", body)
@@ -71,7 +71,7 @@ func TestView_JSON(t *testing.T) {
func TestView_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeViewService{err: errors.New("HTTP error 404: not found")}
err := runView(context.Background(), &ViewOptions{}, nil, svc, "s_missing")
err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s_missing")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -81,7 +81,7 @@ func TestView_NotFound(t *testing.T) {
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{}, nil, svc, "s_min"))
require.NoError(t, runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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:") {
@@ -101,7 +101,7 @@ func TestView_Full_LoadsMessages(t *testing.T) {
{ID: "m2", Role: "assistant", Content: "RAG stands for retrieval-augmented generation.", CreatedAt: time.Date(2026, 5, 15, 14, 32, 5, 0, time.UTC)},
},
}
require.NoError(t, runView(context.Background(), &ViewOptions{Full: true, Limit: 50}, nil, svc, "s_abc"))
require.NoError(t, runView(context.Background(), &ViewOptions{Full: true, Limit: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s_abc"))
got := out.String()
assert.True(t, svc.loadCall.called, "expected LoadMessages to be called")
assert.Equal(t, "s_abc", svc.loadCall.sessionID)
@@ -117,7 +117,7 @@ func TestView_Full_NoMessages(t *testing.T) {
s: &sdk.Session{ID: "s_empty", Title: "Empty"},
msgs: []sdk.Message{},
}
require.NoError(t, runView(context.Background(), &ViewOptions{Full: true, Limit: 50}, nil, svc, "s_empty"))
require.NoError(t, runView(context.Background(), &ViewOptions{Full: true, Limit: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s_empty"))
got := out.String()
assert.Contains(t, got, "Messages (0)")
}
@@ -125,7 +125,7 @@ func TestView_Full_NoMessages(t *testing.T) {
func TestView_Full_LimitInvalid_Zero(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeViewService{s: &sdk.Session{ID: "s"}}
err := runView(context.Background(), &ViewOptions{Full: true, Limit: 0}, nil, svc, "s")
err := runView(context.Background(), &ViewOptions{Full: true, Limit: 0}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s")
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
@@ -133,7 +133,7 @@ func TestView_Full_LimitInvalid_Zero(t *testing.T) {
func TestView_Full_LimitInvalid_TooLarge(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeViewService{s: &sdk.Session{ID: "s"}}
err := runView(context.Background(), &ViewOptions{Full: true, Limit: 1001}, nil, svc, "s")
err := runView(context.Background(), &ViewOptions{Full: true, Limit: 1001}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s")
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
@@ -143,7 +143,7 @@ func TestView_Full_LimitInvalid_TooLarge(t *testing.T) {
func TestView_LimitWithoutFull(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeViewService{s: &sdk.Session{ID: "s"}}
err := runView(context.Background(), &ViewOptions{Full: false, Limit: 100, LimitSet: true}, nil, svc, "s")
err := runView(context.Background(), &ViewOptions{Full: false, Limit: 100, LimitSet: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s")
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
assert.Contains(t, err.Error(), "--limit")
@@ -158,7 +158,7 @@ func TestView_Full_JSON_HasMessages(t *testing.T) {
{ID: "m1", Role: "user", Content: "hi"},
},
}
require.NoError(t, runView(context.Background(), &ViewOptions{Full: true, Limit: 50}, &cmdutil.JSONOptions{}, svc, "s_abc"))
require.NoError(t, runView(context.Background(), &ViewOptions{Full: true, Limit: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "s_abc"))
body := out.String()
assert.Contains(t, body, `"messages":`)
assert.Contains(t, body, `"id":"m1"`)
@@ -170,7 +170,7 @@ func TestView_Full_JSON_HasMessages(t *testing.T) {
func TestView_NoFull_DoesNotCallLoadMessages(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewService{s: &sdk.Session{ID: "s_abc"}}
require.NoError(t, runView(context.Background(), &ViewOptions{}, &cmdutil.JSONOptions{}, svc, "s_abc"))
require.NoError(t, runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "s_abc"))
assert.False(t, svc.loadCall.called, "LoadMessages must not be called without --full")
assert.NotContains(t, out.String(), `"messages":`)
}
+27
View File
@@ -7,6 +7,33 @@ import (
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// ConfirmDestructiveBatch is the multi-id flavor of ConfirmDestructive: same
// behavior matrix (yes / non-TTY / TTY-prompt / user-no) but the prompt text
// reflects the count, not a single id. Used by `doc delete <id> [<id>...]`
// — one -y confirms all items in the batch.
//
// Pass n = total count of items about to be deleted.
func ConfirmDestructiveBatch(p prompt.Prompter, yes, jsonOut bool, what string, n int) error {
if yes {
return nil
}
if !iostreams.IO.IsStdoutTTY() || jsonOut {
return NewError(
CodeInputConfirmationRequired,
fmt.Sprintf("delete %d %s(s) requires explicit confirmation: re-run with -y/--yes", n, what),
)
}
ok, err := p.Confirm(fmt.Sprintf("Delete %d %s(s)? This cannot be undone.", n, what), false)
if err != nil {
return Wrapf(CodeInputMissingFlag, err, "confirm batch delete")
}
if !ok {
fmt.Fprintln(iostreams.IO.Err, "Aborted.")
return NewError(CodeUserAborted, "delete aborted")
}
return nil
}
// ConfirmDestructive guards a destructive operation (delete, force-overwrite)
// behind explicit user approval. Behavior matrix:
//