mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-30 16:53:21 +08:00
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:
+143
-13
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
@@ -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)")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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":`)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user