diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 56a8e08a..e4602a0f 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -42,7 +42,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.26' cache-dependency-path: cli/go.sum - name: secrets present diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 1b39cc52..70651619 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -34,7 +34,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - go: ['1.24'] + go: ['1.26'] steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 diff --git a/cli/AGENTS.md b/cli/AGENTS.md index adf8b156..d524d45d 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -28,10 +28,9 @@ breaking changes are flagged in their PR description and the corresponding `weknora --version` bump — agents should pin a known-good version and re-validate against `--help` output on upgrade. -The model: **gh CLI** as the human-side north star, **lark-cli (larksuite)** -as the agent-affordance reference. The "Output contract" and "Behavioral -rules" sections below are the self-contained specification of that -decision; everything an integrator needs is in this document. +The "Output contract" and "Behavioral rules" sections below are the +self-contained specification of the wire format; everything an integrator +needs is in this document. --- @@ -87,10 +86,9 @@ without natural-language parsing. | `10` | **Confirmation required** for high-risk write | Ask the human, retry with `-y` only after explicit approval | | `130` | Cancelled (SIGINT / Ctrl-C) | Stop, do not retry | -The exit-10 protocol mirrors `lark-cli`'s -([source](https://github.com/larksuite/cli/blob/main/skills/lark-shared/SKILL.md)) -"high-risk write requires confirmation" model. **Never bypass exit 10 by -auto-passing `-y` without explicit user permission.** +Exit 10 is the wire-level signal for "high-risk write needs explicit +confirmation". **Never bypass exit 10 by auto-passing `-y` without +explicit user permission.** --- @@ -104,7 +102,7 @@ weknora kb --help # subtree weknora kb delete --help # single command flags ``` -The command tree follows ` ` (gh style). Verbs are: +The command tree follows ` `. Verbs are: | Verb | Semantics | Example | |---|---|---| @@ -120,20 +118,20 @@ The command tree follows ` ` (gh style). Verbs are: | `use` | Switch active selection | `context use ` | | `add` / `remove` | Manage local config entries | `context add staging --host ...` | -`auth` subtree: `login` / `logout` / `list` / `status` / `refresh`. Mirrors -gh's `auth login / logout / status / switch / list`-style surface; weknora -uses `context use` instead of `auth switch` because contexts carry host + -tenant on top of credentials. `auth refresh` exchanges the stored refresh -token for a new access + refresh pair (OAuth refresh-token grant); it +`auth` subtree: `login` / `logout` / `list` / `status` / `refresh` / +`token`. Context-switching uses `context use ` (WeKnora contexts +bundle host + tenant + credentials, so they need a richer abstraction +than a single per-host token slot). `auth refresh` exchanges the stored +refresh token for a new access + refresh pair (OAuth refresh-token +grant); it errors with `input.invalid_argument` on API-key contexts which have no refresh semantic. Transparent 401 → refresh → retry is wired into the SDK transport (`cli/internal/cmdutil/authretry.go`) with singleflight de-dup, so most callers never need to invoke `auth refresh` explicitly. -`search` subtree: verb-noun (gh `search code/repos/issues/...` shape) — -`search chunks "" --kb X` for hybrid retrieval, `search kb ""` / -`search docs "" --kb X` / `search sessions ""` for client-side -substring filtering on the listing endpoints. +`search` subtree: `search chunks "" --kb X` for hybrid retrieval; +`search kb ""` / `search docs "" --kb X` / `search sessions ""` +for client-side substring filtering on the listing endpoints. `session` subtree: `list` / `view` / `delete` for chat session management. Sessions are the durable wrapper around `chat` invocations. @@ -141,18 +139,17 @@ management. Sessions are the durable wrapper around `chat` invocations. Top-level RAG / connectivity verbs: `chat`, `search`, `api`, `link`, `auth`, `context`, `session`, `doctor`, `version`. -`doctor` is a deliberate divergence from gh / lark (neither ships a -health-check command); the precedent is `flutter doctor` / `brew doctor`. -Kept because RAG deployments routinely break on misconfigured embeddings, -storage backends, and credentials, and a structured 4-status envelope -(ok/warn/fail/skip) is the cleanest agent-readable surface for that. +`doctor` is a deliberate WeKnora addition: RAG deployments routinely +break on misconfigured embeddings, storage backends, and credentials, +and a structured 4-status envelope (ok/warn/fail/skip) is the cleanest +agent-readable surface for that. --- ## Behavioral rules -These mirror lark-cli's per-command `Tips`. Per-command guidance also -appears in each command's `--help` output (under "AI agents:"). +Per-command guidance also appears in each command's `--help` output +(under "AI Agent guidance:"). 1. **Pass `-y/--yes`** on `kb delete` / `doc delete` / `auth logout` when running headless. Without it, you will get exit 10. **Never auto-add @@ -188,12 +185,11 @@ annotation. **No behavior change** — this is help-text rendering only. To suppress detection (e.g. running `weknora` interactively from inside Claude Code without the agent footer): `WEKNORA_NO_AGENT_AUTODETECT=1`. -The omnibus `--agent` mode-switch flag that briefly existed in early v0.2 -was removed: gh / kubectl / aws / docker / flyctl all decline this kind -of flag, since per-command `--json` + TTY auto-detect cover the same -ground without an extra global switch. Stripe's `DetectAIAgent` (the -inspiration) only tags User-Agent for telemetry, never flips behavior; -`weknora` now follows that narrower scope. +The omnibus `--agent` mode-switch flag that briefly existed in early +v0.2 was removed in favor of per-command `--json` + TTY auto-detect, +which covers the same ground without an extra global switch. Agent +detection (`CLAUDECODE` / `CURSOR_AGENT` env) only tags the User-Agent +header for server-side telemetry — it never changes CLI behavior. --- @@ -202,25 +198,32 @@ inspiration) only tags User-Agent for telemetry, never flips behavior; A handful of decisions are referenced inline in the source as `ADR-N`. They live here, alongside the contract they shape. -**ADR-3 — `gh` CLI as the primary mainstream north star.** When weknora's -v0.0/v0.1 surface was audited against gh / kubectl / cargo / npm / git / -docker / flyctl / vercel / supabase / brew, gh emerged as the closest fit -for an opinionated noun-verb tool with a stable JSON envelope and an -agent-aware error model. Documented deviations: +**ADR-3 — opinionated noun-verb tree with stable JSON envelope.** The +v0.0/v0.1 surface was audited against several mainstream CLIs; the +"opinionated noun-verb tool with stable JSON envelope + agent-aware +error model" shape was the closest fit for the agent-friendly contract +this document promises. WeKnora-specific shape choices: -- `link` (project-binding) borrows from `vercel link` / `netlify link` - rather than gh's per-host config model — a `/.weknora/project.yaml` - walk-up matches how RAG users scope work to a specific knowledge base. -- `chat` / `search` are domain-specific verbs gh has no analog for. -- `context use` (kubectl idiom) instead of gh's `auth switch` — weknora's - context bundles host + tenant + credential, which is more than gh's - per-host account model. -- `doctor` (flutter / brew idiom) instead of gh's `status` (which is an - activity feed, different concept) — RAG deployments routinely break on - misconfigured embeddings / storage / credentials, so a structured - 4-status diagnostic is the agent-readable surface for that. +- `link` (project-binding) — `/.weknora/project.yaml` walk-up + matches how RAG users scope work to a specific knowledge base. There + is no per-host config model competing with it; `context use` is the + separate mechanism for switching the credential set. +- `chat` / `search` are domain-specific verbs (LLM streaming + + retrieval) with no equivalent in pure-API CLIs. +- `context use` switches the active credential set; contexts bundle + host + tenant + credential, so a richer abstraction than a single + per-host token slot is required. +- `doctor` (4-status: ok / warn / fail / skip) is the agent-readable + surface for RAG-deployment misconfiguration (embeddings, storage, + credentials) — failure modes that the underlying SDK can't classify + on its own. -Verb canon (gh-canonical): `list / view / create / edit / delete / upload / download / pin / unpin / use`. Locally introduced for resource semantics gh lacks: `empty` (bulk-delete contents preserving the container), `refresh` (token), `add` / `remove` (context CRUD), `link` / `unlink` (project bind / unbind). +Verb canon: `list / view / create / edit / delete / upload / download +/ pin / unpin / use`. WeKnora-specific verbs for resource semantics +the common set lacks: `empty` (bulk-delete contents preserving the +container), `refresh` (token), `add` / `remove` (context CRUD), +`link` / `unlink` (project bind / unbind), `invoke` (run a custom +agent), `serve` (long-lived MCP transport). **ADR-4 — Factory closures + narrow Service interfaces.** `cmdutil.Factory` exposes four lazy closures (Config / Client / Prompter / Secrets) that diff --git a/cli/cmd/agent/invoke.go b/cli/cmd/agent/invoke.go index b1fceda0..00f89a49 100644 --- a/cli/cmd/agent/invoke.go +++ b/cli/cmd/agent/invoke.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -93,7 +93,7 @@ Modes: cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)") cmd.Flags().BoolVar(&opts.NoStream, "no-stream", false, "Buffer the full answer before printing (forces accumulate mode)") cmdutil.AddJSONFlags(cmd, agentInvokeFields) - agent.SetAgentHelp(cmd, "Invokes a custom agent. Default: streams the answer to stdout (TTY) and renders a tool-trace footer. Agent / non-TTY callers should pass --json so the full {answer, references, tool_calls, thinking, session_id, agent_id, query} envelope is emitted once at completion (no partial chunks). Errors: resource.not_found when agent_id unknown; server.session_create_failed when auto-create fails; local.sse_stream_aborted on mid-stream disconnect.") + aiclient.SetAgentHelp(cmd, "Streams an agent's answer over SSE. Pass --json (or run non-TTY) to receive a single completed envelope with answer + references + tool_events instead of partial chunks. Errors: resource.not_found (unknown agent_id) / server.session_create_failed (auto-create) / local.sse_stream_aborted (mid-stream).") return cmd } diff --git a/cli/cmd/agent/list.go b/cli/cmd/agent/list.go index f7c688dc..a67c72ad 100644 --- a/cli/cmd/agent/list.go +++ b/cli/cmd/agent/list.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -31,6 +31,14 @@ type ListService interface { ListAgents(ctx context.Context) ([]sdk.Agent, error) } +// ListOptions captures `agent list` filter flag state. +type ListOptions struct { + // Limit caps the returned slice client-side. 0 = no cap, 1..10000 = explicit. + // The agent list SDK is unpaginated; --all-pages is intentionally not + // exposed because it would be a no-op. + Limit int +} + // listResult is the typed payload emitted under data.items. type listResult struct { Items []sdk.Agent `json:"items"` @@ -38,6 +46,7 @@ type listResult struct { // NewCmdList builds `weknora agent list`. func NewCmdList(f *cmdutil.Factory) *cobra.Command { + opts := &ListOptions{} cmd := &cobra.Command{ Use: "list", Short: "List custom agents visible to the active tenant", @@ -51,15 +60,25 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { if err != nil { return err } - return runList(c.Context(), jopts, cli) + return runList(c.Context(), opts, jopts, cli) }, } + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (0 = no cap, 1..10000 = explicit)") cmdutil.AddJSONFlags(cmd, agentListFields) - agent.SetAgentHelp(cmd, "Lists tenant-visible agents (built-in + custom). Returns data.items: [{id, name, description, ...}]; empty array when none. Use --json id,name to project, --jq for arbitrary reshape.") + aiclient.SetAgentHelp(cmd, "Lists tenant-visible agents (built-in + custom). Returns data.items: [{id, name, description, ...}]; empty array when none. --limit caps the returned slice. Use --json id,name to project, --jq for arbitrary reshape.") return cmd } -func runList(ctx context.Context, jopts *cmdutil.JSONOptions, svc ListService) error { +func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, svc ListService) error { + if opts == nil { + opts = &ListOptions{} + } + if opts.Limit < 0 || opts.Limit > 10000 { + return &cmdutil.Error{ + Code: cmdutil.CodeInputInvalidArgument, + Message: fmt.Sprintf("--limit must be in 0..10000 (0 = no cap), got %d", opts.Limit), + } + } items, err := svc.ListAgents(ctx) if err != nil { return cmdutil.WrapHTTP(err, "list agents") @@ -72,6 +91,10 @@ func runList(ctx context.Context, jopts *cmdutil.JSONOptions, svc ListService) e sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) }) + // --limit applies after sort so the cap returns the top-N most-recent. + if opts.Limit > 0 && len(items) > opts.Limit { + items = items[:opts.Limit] + } if jopts.Enabled() { return format.WriteEnvelopeFiltered( diff --git a/cli/cmd/agent/list_test.go b/cli/cmd/agent/list_test.go index 2a9afb93..5c1308cc 100644 --- a/cli/cmd/agent/list_test.go +++ b/cli/cmd/agent/list_test.go @@ -3,6 +3,8 @@ package agentcmd import ( "context" "encoding/json" + "errors" + "fmt" "strings" "testing" "time" @@ -23,7 +25,7 @@ func (f *fakeListSvc) ListAgents(_ context.Context) ([]sdk.Agent, error) { func TestList_Empty_Human(t *testing.T) { out, _ := iostreams.SetForTest(t) - if err := runList(context.Background(), nil, &fakeListSvc{}); err != nil { + if err := runList(context.Background(), &ListOptions{}, nil, &fakeListSvc{}); err != nil { t.Fatalf("runList: %v", err) } if !strings.Contains(out.String(), "(no agents)") { @@ -33,7 +35,7 @@ func TestList_Empty_Human(t *testing.T) { func TestList_Empty_JSON(t *testing.T) { out, _ := iostreams.SetForTest(t) - if err := runList(context.Background(), &cmdutil.JSONOptions{}, &fakeListSvc{}); err != nil { + if err := runList(context.Background(), &ListOptions{}, &cmdutil.JSONOptions{}, &fakeListSvc{}); err != nil { t.Fatalf("runList: %v", err) } if !strings.Contains(out.String(), `"items":[]`) { @@ -48,7 +50,7 @@ func TestList_NonEmpty_Human_RendersColumns(t *testing.T) { {ID: "ag_a", Name: "Research", IsBuiltin: true, UpdatedAt: now.Add(-1 * time.Hour)}, {ID: "ag_b", Name: "Triage", UpdatedAt: now.Add(-3 * 24 * time.Hour)}, } - if err := runList(context.Background(), nil, &fakeListSvc{items: items}); err != nil { + if err := runList(context.Background(), &ListOptions{}, nil, &fakeListSvc{items: items}); err != nil { t.Fatalf("runList: %v", err) } got := out.String() @@ -67,7 +69,7 @@ func TestList_NonEmpty_JSON_SortsByUpdatedAtDesc(t *testing.T) { {ID: "ag_new", Name: "new", UpdatedAt: now}, {ID: "ag_mid", Name: "mid", UpdatedAt: now.Add(-1 * time.Hour)}, } - if err := runList(context.Background(), &cmdutil.JSONOptions{}, &fakeListSvc{items: items}); err != nil { + if err := runList(context.Background(), &ListOptions{}, &cmdutil.JSONOptions{}, &fakeListSvc{items: items}); err != nil { t.Fatalf("runList: %v", err) } var env struct { @@ -95,7 +97,7 @@ func TestList_JSON_FieldFilter(t *testing.T) { {ID: "ag_x", Name: "Foo", Description: "long description"}, } jopts := &cmdutil.JSONOptions{Fields: []string{"id", "name"}} - if err := runList(context.Background(), jopts, &fakeListSvc{items: items}); err != nil { + if err := runList(context.Background(), &ListOptions{}, jopts, &fakeListSvc{items: items}); err != nil { t.Fatalf("runList: %v", err) } var env struct { @@ -110,3 +112,54 @@ func TestList_JSON_FieldFilter(t *testing.T) { t.Errorf("description should be filtered out: %+v", env.Data.Items[0]) } } + +// makeAgents returns N Agents with distinct IDs and descending UpdatedAt. +func makeAgents(n int) []sdk.Agent { + base := time.Now() + out := make([]sdk.Agent, n) + for i := 0; i < n; i++ { + out[i] = sdk.Agent{ + ID: fmt.Sprintf("ag_%02d", i), + Name: fmt.Sprintf("name-%02d", i), + UpdatedAt: base.Add(-time.Duration(i) * time.Hour), + } + } + return out +} + +func TestList_Limit_CapsResults(t *testing.T) { + out, _ := iostreams.SetForTest(t) + if err := runList(context.Background(), &ListOptions{Limit: 5}, &cmdutil.JSONOptions{}, &fakeListSvc{items: makeAgents(20)}); err != nil { + t.Fatalf("runList: %v", err) + } + got := strings.Count(out.String(), `"id":"ag_`) + if got != 5 { + t.Errorf("--limit 5 must slice 20 down to 5; got %d", got) + } +} + +func TestList_Limit_Zero_NoCap(t *testing.T) { + out, _ := iostreams.SetForTest(t) + if err := runList(context.Background(), &ListOptions{Limit: 0}, &cmdutil.JSONOptions{}, &fakeListSvc{items: makeAgents(7)}); err != nil { + t.Fatalf("runList: %v", err) + } + got := strings.Count(out.String(), `"id":"ag_`) + if got != 7 { + t.Errorf("--limit 0 must not cap; got %d, want 7", got) + } +} + +func TestList_Limit_Negative_Rejected(t *testing.T) { + _, _ = iostreams.SetForTest(t) + err := runList(context.Background(), &ListOptions{Limit: -1}, nil, &fakeListSvc{items: makeAgents(2)}) + if err == nil { + t.Fatal("expected error for negative --limit") + } + var typed *cmdutil.Error + if !errors.As(err, &typed) { + t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err) + } + if typed.Code != cmdutil.CodeInputInvalidArgument { + t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code) + } +} diff --git a/cli/cmd/agent/view.go b/cli/cmd/agent/view.go index 77f35e93..7e63ab9e 100644 --- a/cli/cmd/agent/view.go +++ b/cli/cmd/agent/view.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -54,7 +54,7 @@ struct — or --jq '.data.config' to extract just the config.`, }, } cmdutil.AddJSONFlags(cmd, agentViewFields) - agent.SetAgentHelp(cmd, "Fetches an agent by ID. Returns data: full sdk.Agent (with nested config). Errors: resource.not_found when the agent ID does not exist or is not visible to the active tenant.") + aiclient.SetAgentHelp(cmd, "Fetches an agent by ID. Returns data: full sdk.Agent (with nested config). Errors: resource.not_found when the agent ID does not exist or is not visible to the active tenant.") return cmd } diff --git a/cli/cmd/api/api.go b/cli/cmd/api/api.go index 1132d3ee..a4f66365 100644 --- a/cli/cmd/api/api.go +++ b/cli/cmd/api/api.go @@ -18,7 +18,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -98,7 +98,7 @@ Examples: cmd.Flags().StringVar(&opts.Input, "input", "", "Read request body from file (use `-` for stdin)") cmdutil.AddJSONFlags(cmd, apiFields) cmd.MarkFlagsMutuallyExclusive("data", "input") - agent.SetAgentHelp(cmd, "Raw HTTP passthrough to the WeKnora server. Use when no typed command exists for the endpoint. Headers (auth / tenant / request-id) are injected from the active context. Without --json the response body streams to stdout verbatim. With --json: data is {status, headers, body} where body is the parsed JSON if response is JSON, else the raw string. NOTE: --json field filtering (`--json id,name`) is NOT supported for api passthrough — the response shape varies per endpoint and the CLI cannot pre-project unknown schemas; any field list is ignored. Use --jq for arbitrary reshape. Non-2xx responses surface as a typed error (4xx → input.invalid_argument / auth.* / resource.not_found per ClassifyHTTPStatus; 5xx → server.error / network.error). DELETE through `weknora api` triggers exit-10 confirm just like `weknora kb delete`. Mutual exclusion: --data and --input cannot both be set (input.invalid_argument).") + aiclient.SetAgentHelp(cmd, "Raw HTTP passthrough. Auth / tenant / request-id headers injected from the active context. --json wraps as {status, headers, body}; --json field filtering is ignored here (response shape unknown to CLI) — use --jq for reshape. DELETE triggers exit-10 confirm. Non-2xx maps to typed errors via ClassifyHTTPStatus (4xx → input.invalid_argument / auth.* / resource.not_found; 5xx → server.error). --data and --input are mutually exclusive.") return cmd } diff --git a/cli/cmd/auth/list.go b/cli/cmd/auth/list.go index a307eaaa..c2322066 100644 --- a/cli/cmd/auth/list.go +++ b/cli/cmd/auth/list.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -46,7 +46,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { }, } cmdutil.AddJSONFlags(cmd, authListFields) - agent.SetAgentHelp(cmd, "Lists configured auth contexts (name/host/user/mode/current). Read-only, no network, no keyring access. Use to confirm context names before --context or `auth login --name`.") + aiclient.SetAgentHelp(cmd, "Lists configured auth contexts (name/host/user/mode/current). Read-only, no network, no keyring access. Use to confirm context names before --context or `auth login --name`.") return cmd } diff --git a/cli/cmd/auth/login.go b/cli/cmd/auth/login.go index 3fdcf6a8..66040bf9 100644 --- a/cli/cmd/auth/login.go +++ b/cli/cmd/auth/login.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/config" "github.com/Tencent/WeKnora/cli/internal/format" @@ -76,7 +76,7 @@ the current_context in ~/.config/weknora/config.yaml.`, cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password") cmdutil.AddJSONFlags(cmd, authLoginFields) cmdutil.MustRequireFlag(cmd, "host") - agent.SetAgentHelp(cmd, "Authenticates and stores credentials. --with-token reads an API key from stdin (no password prompt, agent-safe). Otherwise email/password prompts fire — non-TTY callers must pipe `--with-token` or pre-set --name. Errors: auth.bad_credential on wrong password; input.invalid_argument on bad --host; input.missing_flag when --with-token has empty stdin.") + aiclient.SetAgentHelp(cmd, "Authenticates and stores credentials. --with-token reads an API key from stdin (no password prompt, agent-safe). Otherwise email/password prompts fire — non-TTY callers must pipe `--with-token` or pre-set --name. Errors: auth.bad_credential on wrong password; input.invalid_argument on bad --host; input.missing_flag when --with-token has empty stdin.") return cmd } diff --git a/cli/cmd/auth/logout.go b/cli/cmd/auth/logout.go index ee3af23f..473a7900 100644 --- a/cli/cmd/auth/logout.go +++ b/cli/cmd/auth/logout.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/config" "github.com/Tencent/WeKnora/cli/internal/format" @@ -59,7 +59,7 @@ accepted until it expires.`, cmd.Flags().BoolVar(&opts.All, "all", false, "Log out of every configured context") cmdutil.AddJSONFlags(cmd, authLogoutFields) cmd.MarkFlagsMutuallyExclusive("name", "all") - agent.SetAgentHelp(cmd, "Clears local credentials only; the server-side token / api-key continues to be valid until expired or rotated. Returns data.removed: [...names]. Errors: auth.unauthenticated when no contexts configured.") + aiclient.SetAgentHelp(cmd, "Clears local credentials only; the server-side token / api-key continues to be valid until expired or rotated. Returns data.removed: [...names]. Errors: auth.unauthenticated when no contexts configured.") return cmd } diff --git a/cli/cmd/auth/refresh.go b/cli/cmd/auth/refresh.go index 9822dc63..471ea658 100644 --- a/cli/cmd/auth/refresh.go +++ b/cli/cmd/auth/refresh.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -59,7 +59,7 @@ refresh semantic. Rotate the key in the server UI instead.`, } cmd.Flags().StringVar(&opts.Name, "name", "", "Context to refresh (defaults to the current context)") cmdutil.AddJSONFlags(cmd, authRefreshFields) - agent.SetAgentHelp(cmd, "Renews the access token using the stored refresh token. Errors with auth.token_expired when refresh itself is rejected — surface the hint to re-run auth login.") + aiclient.SetAgentHelp(cmd, "Renews the access token using the stored refresh token. Errors with auth.token_expired when refresh itself is rejected — surface the hint to re-run auth login.") return cmd } diff --git a/cli/cmd/auth/status.go b/cli/cmd/auth/status.go index 31a2baf5..d10ec7eb 100644 --- a/cli/cmd/auth/status.go +++ b/cli/cmd/auth/status.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -52,7 +52,7 @@ func NewCmdStatus(f *cmdutil.Factory) *cobra.Command { }, } cmdutil.AddJSONFlags(cmd, authStatusFields) - agent.SetAgentHelp(cmd, "Live-checks the active credential by calling /auth/me. Returns {context, user_id, email, tenant_id, tenant_name}. Errors: auth.unauthenticated when token is invalid or missing (run `auth login` / `auth refresh`).") + aiclient.SetAgentHelp(cmd, "Live-checks the active credential by calling /auth/me. Returns {context, user_id, email, tenant_id, tenant_name}. Errors: auth.unauthenticated when token is invalid or missing (run `auth login` / `auth refresh`).") return cmd } diff --git a/cli/cmd/auth/token.go b/cli/cmd/auth/token.go index c069340f..91cbd700 100644 --- a/cli/cmd/auth/token.go +++ b/cli/cmd/auth/token.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -28,8 +28,8 @@ type tokenResult struct { // curl -H "Authorization: Bearer $WEKNORA_TOKEN" ... # JWT mode // curl -H "X-API-Key: $WEKNORA_TOKEN" ... # api-key mode // -// Mirrors gh's `gh auth token`. The user is responsible for constructing -// the appropriate header — `auth list` shows which mode each context uses. +// The user is responsible for constructing the appropriate header — +// `auth list` shows which mode each context uses. // // Default output: raw token on stdout, no trailing newline (clean $(...)). // `--json[=fields]` wraps in envelope {token, mode, context}. @@ -61,7 +61,7 @@ to see which mode each context uses, and construct the matching HTTP header: }, } cmdutil.AddJSONFlags(cmd, authTokenFields) - agent.SetAgentHelp(cmd, "Prints the active context's credential to stdout for scripting. Default: raw secret, no trailing newline. With --json: envelope {token, mode, context}. Errors: auth.unauthenticated when no active context or no stored credential (run `auth login`); local.keychain_denied when the keyring rejects the read.") + aiclient.SetAgentHelp(cmd, "Prints the active context's credential to stdout for scripting. Default: raw secret, no trailing newline. With --json: envelope {token, mode, context}. Errors: auth.unauthenticated when no active context or no stored credential (run `auth login`); local.keychain_denied when the keyring rejects the read.") return cmd } @@ -125,5 +125,17 @@ func runToken(f *cmdutil.Factory, jopts *cmdutil.JSONOptions) error { // No trailing newline — clean $(weknora auth token) substitution. fmt.Fprint(iostreams.IO.Out, token) + // Defensive hint to stderr when stdout is an interactive terminal: + // the user likely didn't mean to display the secret on screen. + // stderr-only so scripts (always non-TTY) are unaffected. Mode-specific + // because api-key tokens are long-lived and rotation is the only + // recourse on leak — bearer tokens self-expire via refresh. + if iostreams.IO.IsStdoutTTY() { + fmt.Fprintln(iostreams.IO.Err) + fmt.Fprintln(iostreams.IO.Err, "hint: pipe to $(weknora auth token) to capture; this terminal scrollback now contains the secret") + if mode == ModeAPIKey { + fmt.Fprintln(iostreams.IO.Err, "note: api-key credentials are long-lived — rotate via your auth provider if exposed (no `auth refresh` path)") + } + } return nil } diff --git a/cli/cmd/auth/token_test.go b/cli/cmd/auth/token_test.go index 25ac4f0c..dbb0918a 100644 --- a/cli/cmd/auth/token_test.go +++ b/cli/cmd/auth/token_test.go @@ -201,3 +201,98 @@ func TestAuthToken_ContextWithNoCredentialRefs(t *testing.T) { t.Errorf("want auth.*, got %v", err) } } + +// --- stderr advisory tests -------------------------------------------------- +// +// auth token prints the token to stdout unconditionally. When stdout is an +// interactive terminal, it ALSO writes a stderr advisory ("you just put the +// secret in your scrollback") + a mode-specific rotation note for api-key +// credentials. The stdout half must stay clean under all modes so $(...) +// substitution is unaffected — tests assert both axes. + +func makeBearerCfg() (*config.Config, *secrets.MemStore) { + cfg := &config.Config{ + CurrentContext: "prod", + Contexts: map[string]config.Context{ + "prod": {Host: "https://kb.example.com", TokenRef: "prod:access"}, + }, + } + store := secrets.NewMemStore() + _ = store.Set("prod", "access", "jwt-xyz") + return cfg, store +} + +func makeAPIKeyCfg() (*config.Config, *secrets.MemStore) { + cfg := &config.Config{ + CurrentContext: "ci", + Contexts: map[string]config.Context{ + "ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"}, + }, + } + store := secrets.NewMemStore() + _ = store.Set("ci", "api_key", "sk_42") + return cfg, store +} + +func TestAuthToken_NonTTY_NoStderrHint(t *testing.T) { + cfg, store := makeBearerCfg() + out, errBuf := iostreams.SetForTest(t) + if err := runToken(tokenTestFactory(t, cfg, store), nil); err != nil { + t.Fatalf("runToken: %v", err) + } + if out.String() != "jwt-xyz" { + t.Errorf("stdout = %q, want %q", out.String(), "jwt-xyz") + } + if errBuf.Len() != 0 { + t.Errorf("non-TTY stderr should be empty (scripts depend on this), got %q", errBuf.String()) + } +} + +func TestAuthToken_TTY_BearerMode_StderrHintNoRotationNote(t *testing.T) { + cfg, store := makeBearerCfg() + out, errBuf := iostreams.SetForTestWithTTY(t) + if err := runToken(tokenTestFactory(t, cfg, store), nil); err != nil { + t.Fatalf("runToken: %v", err) + } + if out.String() != "jwt-xyz" { + t.Errorf("stdout = %q, want raw token only", out.String()) + } + if !strings.Contains(errBuf.String(), "scrollback") { + t.Errorf("expected stderr scrollback hint on TTY, got %q", errBuf.String()) + } + if strings.Contains(errBuf.String(), "api-key") { + t.Errorf("bearer mode should not surface the api-key rotation note: %q", errBuf.String()) + } +} + +func TestAuthToken_TTY_APIKeyMode_IncludesRotationNote(t *testing.T) { + cfg, store := makeAPIKeyCfg() + out, errBuf := iostreams.SetForTestWithTTY(t) + if err := runToken(tokenTestFactory(t, cfg, store), nil); err != nil { + t.Fatalf("runToken: %v", err) + } + if out.String() != "sk_42" { + t.Errorf("stdout = %q, want raw token only", out.String()) + } + stderr := errBuf.String() + if !strings.Contains(stderr, "scrollback") { + t.Errorf("api-key TTY stderr should still include the scrollback hint, got %q", stderr) + } + if !strings.Contains(stderr, "long-lived") || !strings.Contains(stderr, "rotate") { + t.Errorf("api-key mode should include rotation note, got %q", stderr) + } +} + +func TestAuthToken_TTY_JSONMode_NoStderrHint(t *testing.T) { + // --json output mode targets script/agent consumers even when stdout + // happens to be a TTY (e.g. an IDE running the CLI on the user's + // behalf). Hint would pollute their parsing — suppress. + cfg, store := makeBearerCfg() + _, errBuf := iostreams.SetForTestWithTTY(t) + if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.JSONOptions{}); err != nil { + t.Fatalf("runToken: %v", err) + } + if errBuf.Len() != 0 { + t.Errorf("JSON mode should not emit stderr hint, got %q", errBuf.String()) + } +} diff --git a/cli/cmd/chat/chat.go b/cli/cmd/chat/chat.go index 61690760..c2545e66 100644 --- a/cli/cmd/chat/chat.go +++ b/cli/cmd/chat/chat.go @@ -27,7 +27,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -38,7 +38,8 @@ import ( // chatFields enumerates the fields surfaced for `--json` discovery on `chat`. // Mirrors the chatData struct json tags. var chatFields = []string{ - "answer", "references", "session_id", "assistant_message_id", "kb_id", "query", + "answer", "references", "thinking", + "session_id", "assistant_message_id", "kb_id", "query", } type Options struct { @@ -62,6 +63,11 @@ type chatService interface { type chatData struct { Answer string `json:"answer"` References []*sdk.SearchResult `json:"references"` + // Thinking holds the reasoning / reflection text emitted by reasoning + // models (GPT-5, Claude extended thinking) via response_type=thinking + // frames. Omitted when empty (non-reasoning model or model didn't + // surface reasoning for this query). + Thinking string `json:"thinking,omitempty"` SessionID string `json:"session_id"` AssistantMessageID string `json:"assistant_message_id,omitempty"` KBID string `json:"kb_id"` @@ -110,7 +116,7 @@ Modes: cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)") cmd.Flags().BoolVar(&opts.NoStream, "no-stream", false, "Buffer the full answer before printing (forces accumulate mode)") cmdutil.AddJSONFlags(cmd, chatFields) - agent.SetAgentHelp(cmd, "Streams an LLM answer over SSE. Agent / non-TTY callers should pass --json so the full {answer, references, session_id, assistant_message_id} envelope is emitted at completion (no partial chunks). Pass --session to thread follow-ups. Errors: server.session_create_failed when auto-create fails; local.sse_stream_aborted on mid-stream disconnect.") + aiclient.SetAgentHelp(cmd, "Streams an LLM answer over SSE. Agent / non-TTY callers should pass --json so the full {answer, references, session_id, assistant_message_id} envelope is emitted at completion (no partial chunks). Pass --session to thread follow-ups. Errors: server.session_create_failed when auto-create fails; local.sse_stream_aborted on mid-stream disconnect.") return cmd } @@ -229,6 +235,7 @@ func runChat(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc data := chatData{ Answer: answer, References: references, + Thinking: acc.Thinking(), SessionID: sid, AssistantMessageID: acc.AssistantMessageID, KBID: opts.KBID, diff --git a/cli/cmd/context/add.go b/cli/cmd/context/add.go index 996e8b27..6b6d3f6b 100644 --- a/cli/cmd/context/add.go +++ b/cli/cmd/context/add.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/config" "github.com/Tencent/WeKnora/cli/internal/format" @@ -65,7 +65,7 @@ adds leave the current context untouched.`, cmd.Flags().StringVar(&opts.User, "user", "", "Account email shown in 'context list' (optional, cosmetic only)") cmdutil.AddJSONFlags(cmd, contextAddFields) _ = cmd.MarkFlagRequired("host") - agent.SetAgentHelp(cmd, "Adds a context. First context added auto-becomes current. Errors with resource.already_exists if name collides; input.invalid_argument if --host is not an absolute http(s) URL.") + aiclient.SetAgentHelp(cmd, "Adds a context. First context added auto-becomes current. Errors with resource.already_exists if name collides; input.invalid_argument if --host is not an absolute http(s) URL.") return cmd } diff --git a/cli/cmd/context/list.go b/cli/cmd/context/list.go index cda520e4..7ec35226 100644 --- a/cli/cmd/context/list.go +++ b/cli/cmd/context/list.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/config" "github.com/Tencent/WeKnora/cli/internal/format" @@ -52,7 +52,7 @@ the CLI can talk to; "auth list" is the catalog of *how*.`, }, } cmdutil.AddJSONFlags(cmd, contextListFields) - agent.SetAgentHelp(cmd, "Lists CLI contexts (name/host/user/current). Read-only, no network. Use this before context use to verify the target name exists.") + aiclient.SetAgentHelp(cmd, "Lists CLI contexts (name/host/user/current). Read-only, no network. Use this before context use to verify the target name exists.") return cmd } diff --git a/cli/cmd/context/remove.go b/cli/cmd/context/remove.go index 7ea3ca42..4ba671f4 100644 --- a/cli/cmd/context/remove.go +++ b/cli/cmd/context/remove.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/config" "github.com/Tencent/WeKnora/cli/internal/format" @@ -68,7 +68,7 @@ in scripted / --json invocations (exit code 10; see cli/AGENTS.md).`, }, } cmdutil.AddJSONFlags(cmd, contextRemoveFields) - agent.SetAgentHelp(cmd, "Removes a context entry. Removing the current context requires -y in non-TTY/--json (exit-10). Always best-effort clears keyring refs.") + aiclient.SetAgentHelp(cmd, "Removes a context entry. Removing the current context requires -y in non-TTY/--json (exit-10). Always best-effort clears keyring refs.") return cmd } diff --git a/cli/cmd/context/use.go b/cli/cmd/context/use.go index eeaf5ccf..33d2723f 100644 --- a/cli/cmd/context/use.go +++ b/cli/cmd/context/use.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/config" "github.com/Tencent/WeKnora/cli/internal/format" @@ -35,7 +35,7 @@ the global --context flag instead, which writes nothing to disk.`, return runUse(args[0]) }, } - agent.SetAgentHelp(cmd, "Switches default CLI context. Returns previous_context + current_context. Errors with hint when name unknown.") + aiclient.SetAgentHelp(cmd, "Switches default CLI context. Returns previous_context + current_context. Errors with hint when name unknown.") return cmd } diff --git a/cli/cmd/doc/delete.go b/cli/cmd/doc/delete.go index 95f543fe..278c9b4e 100644 --- a/cli/cmd/doc/delete.go +++ b/cli/cmd/doc/delete.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -70,7 +70,7 @@ without the user's explicit go-ahead.`, }, } cmdutil.AddJSONFlags(cmd, docDeleteFields) - agent.SetAgentHelp(cmd, "Destructively deletes one document by id. ALWAYS pass -y/--yes in agent mode (no TTY ⇒ confirm prompt fails). Returns data: {id, deleted:true}.") + aiclient.SetAgentHelp(cmd, "Destructively deletes one document by id. ALWAYS pass -y/--yes in agent mode (no TTY ⇒ confirm prompt fails). Returns data: {id, deleted:true}.") return cmd } diff --git a/cli/cmd/doc/download.go b/cli/cmd/doc/download.go index f219da74..dd92cde6 100644 --- a/cli/cmd/doc/download.go +++ b/cli/cmd/doc/download.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/iostreams" ) @@ -57,7 +57,7 @@ Existing files are NOT overwritten unless --clobber is passed.`, } cmd.Flags().StringVarP(&opts.Output, "output", "O", "", `Output path; "-" for stdout. Defaults to the server-suggested filename.`) cmd.Flags().BoolVar(&opts.Clobber, "clobber", false, "Overwrite the output file if it already exists") - agent.SetAgentHelp(cmd, "Downloads a document. Agents that pipe the bytes should pass --output - (stdout). The exit code is 0 on success, 1 on resource.not_found, 5 on missing --output when the server didn't supply a filename.") + aiclient.SetAgentHelp(cmd, "Downloads a document. Agents that pipe the bytes should pass --output - (stdout). The exit code is 0 on success, 1 on resource.not_found, 5 on missing --output when the server didn't supply a filename.") return cmd } diff --git a/cli/cmd/doc/list.go b/cli/cmd/doc/list.go index 3f2ab06f..748dd628 100644 --- a/cli/cmd/doc/list.go +++ b/cli/cmd/doc/list.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -31,9 +31,15 @@ var docListFields = []string{ } type ListOptions struct { - Page int - PageSize int + PageSize int // Items per server batch. With --all-pages, controls + // per-request load. Without, controls the single page size. Status string // --status: filter by parse_status (server-side query param) + // Limit caps the returned items client-side (default 30; 0 = no cap). + // Applied after pagination / --all-pages accumulation and sort. + Limit int + // AllPages walks server pages internally, accumulating items until + // total exhausted or --limit hit. + AllPages bool } // docListStatusValues mirrors internal/types/knowledge.go ParseStatus* @@ -48,17 +54,12 @@ type ListService interface { ListKnowledgeWithFilter(ctx context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) } -// listResult is the typed payload emitted under data on success. -// -// Items is non-nil even when empty (json:"[]" not "null") so agents can iterate -// without nil-checks. Page metadata is duplicated here (and not in _meta) to -// keep the payload self-describing for downstream consumers that strip _meta. +// listResult is the typed payload emitted under data. Pagination +// metadata (page / page_size / total) lives in envelope `_meta`, and +// kb_id is in `_meta.kb_id`, so this stays a single-shape `{items}` +// payload consistent with every other list command. type listResult struct { - Items []sdk.Knowledge `json:"items"` - Page int `json:"page"` - PageSize int `json:"page_size"` - Total int64 `json:"total"` - KBID string `json:"kb_id"` + Items []sdk.Knowledge `json:"items"` } // NewCmdList builds `weknora doc list`. @@ -77,7 +78,7 @@ 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 --page 2 --json # paginated envelope output`, + weknora doc list --all-pages --json # walk every page`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { jopts, err := cmdutil.CheckJSONFlags(c) @@ -98,27 +99,28 @@ backend storage order is not guaranteed and varies between deployments.`, // --kb is read by Factory.ResolveKB; declare it here so cobra parses the // value into the command's flag set. cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)") - cmd.Flags().IntVar(&opts.Page, "page", 1, "Page number (1-based)") - cmd.Flags().IntVar(&opts.PageSize, "page-size", 20, "Items per page (1..1000)") + cmd.Flags().IntVar(&opts.PageSize, "page-size", 50, "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.Status, "status", "", "Filter by parse status: pending | processing | completed | failed") cmdutil.AddJSONFlags(cmd, docListFields) - agent.SetAgentHelp(cmd, "Lists docs in the resolved KB. Returns data: {items, page, page_size, total, kb_id}; pass --kb when not running inside a project. --status (server-side filter) restricts to a parse-pipeline state — `failed` is the practical one for surfacing ingestion errors.") + aiclient.SetAgentHelp(cmd, "Lists docs in the resolved KB. data.{items}; pagination + kb_id in _meta.{page, page_size, total, kb_id}. --status filters server-side; `failed` surfaces ingestion errors. --all-pages walks every server page until exhausted (capped by --limit), useful for one-shot exports.") return cmd } func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, svc ListService, kbID string) error { - if opts.Page < 1 { - return &cmdutil.Error{ - Code: cmdutil.CodeInputInvalidArgument, - Message: fmt.Sprintf("--page must be >= 1, got %d", opts.Page), - } - } if opts.PageSize < 1 || opts.PageSize > 1000 { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, Message: fmt.Sprintf("--page-size must be in 1..1000, got %d", opts.PageSize), } } + if opts.Limit < 0 || opts.Limit > 10000 { + return &cmdutil.Error{ + Code: cmdutil.CodeInputInvalidArgument, + Message: fmt.Sprintf("--limit must be in 0..10000 (0 = no cap), got %d", opts.Limit), + } + } if opts.Status != "" && !validDocListStatus(opts.Status) { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, @@ -126,10 +128,41 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, strings.Join(docListStatusValues, " | "), opts.Status), } } - items, total, err := svc.ListKnowledgeWithFilter(ctx, kbID, opts.Page, opts.PageSize, - sdk.KnowledgeListFilter{ParseStatus: opts.Status}) - if err != nil { - return cmdutil.WrapHTTP(err, "list documents") + filter := sdk.KnowledgeListFilter{ParseStatus: opts.Status} + + // Pagination is always 1-indexed internally. --all-pages walks; the + // non-walking path returns the first page only. + var ( + items []sdk.Knowledge + total int64 + ) + if opts.AllPages { + accum := make([]sdk.Knowledge, 0) + page := 1 + for { + chunk, t, err := svc.ListKnowledgeWithFilter(ctx, kbID, page, opts.PageSize, filter) + if err != nil { + return cmdutil.WrapHTTP(err, "list documents") + } + total = t + accum = append(accum, chunk...) + if opts.Limit > 0 && len(accum) >= opts.Limit { + accum = accum[:opts.Limit] + break + } + if int64(page*opts.PageSize) >= total || len(chunk) == 0 { + break + } + page++ + } + items = accum + } else { + chunk, t, err := svc.ListKnowledgeWithFilter(ctx, kbID, 1, opts.PageSize, filter) + if err != nil { + return cmdutil.WrapHTTP(err, "list documents") + } + items = chunk + total = t } if items == nil { items = []sdk.Knowledge{} // ensure JSON [] not null @@ -140,16 +173,26 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) }) - - r := listResult{ - Items: items, - Page: opts.Page, - PageSize: opts.PageSize, - Total: total, - KBID: kbID, + // --limit applies after sort so users get the top-N most-recent items + // when combined with a single-page fetch where page_size > limit. + if opts.Limit > 0 && len(items) > opts.Limit { + items = items[:opts.Limit] } + + r := listResult{Items: items} if jopts.Enabled() { - return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.Success(r, &format.Meta{KBID: kbID}), jopts.Fields, jopts.JQ) + // --all-pages collapses pagination into a single conceptual page, + // so the meta reflects "you got everything" semantics. + meta := &format.Meta{ + KBID: kbID, + Page: 1, + PageSize: opts.PageSize, + Total: total, + } + if !opts.AllPages { + meta.HasMore = int64(opts.PageSize) < total + } + return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.Success(r, meta), jopts.Fields, jopts.JQ) } if len(items) == 0 { diff --git a/cli/cmd/doc/list_test.go b/cli/cmd/doc/list_test.go index b06cea7e..43933ed0 100644 --- a/cli/cmd/doc/list_test.go +++ b/cli/cmd/doc/list_test.go @@ -3,6 +3,7 @@ package doc import ( "context" "errors" + "fmt" "os" "strings" "testing" @@ -55,7 +56,7 @@ func TestList_Success_Human(t *testing.T) { {ID: "doc2", FileName: "beta.md", FileSize: 0, ParseStatus: "pending", UpdatedAt: now.Add(-2 * 24 * time.Hour)}, } svc := &fakeListSvc{items: items, total: 2} - opts := &ListOptions{Page: 1, PageSize: 20} + opts := &ListOptions{PageSize: 20} require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx")) assert.Equal(t, "kb_xxx", svc.got.kbID) @@ -74,7 +75,7 @@ func TestList_Success_Human(t *testing.T) { 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{Page: 1, PageSize: 20} + opts := &ListOptions{PageSize: 20} require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx")) got := out.String() @@ -90,7 +91,7 @@ func TestList_Success_JSON(t *testing.T) { func TestList_Empty_Human(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeListSvc{items: nil, total: 0} - opts := &ListOptions{Page: 1, PageSize: 20} + opts := &ListOptions{PageSize: 20} require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx")) assert.Contains(t, out.String(), "(no documents)") } @@ -98,7 +99,7 @@ func TestList_Empty_Human(t *testing.T) { func TestList_Empty_JSON(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeListSvc{items: nil, total: 0} - opts := &ListOptions{Page: 1, PageSize: 20} + opts := &ListOptions{PageSize: 20} require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx")) got := out.String() @@ -109,7 +110,7 @@ func TestList_Empty_JSON(t *testing.T) { func TestList_HTTPError_500(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeListSvc{err: errors.New("HTTP error 500: internal")} - opts := &ListOptions{Page: 1, PageSize: 20} + opts := &ListOptions{PageSize: 20} err := runList(context.Background(), opts, nil, svc, "kb_xxx") require.Error(t, err) @@ -188,7 +189,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{Page: 1, PageSize: 20}, nil, svc, "kb_xxx")) + require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 20}, nil, svc, "kb_xxx")) got := out.String() newIdx := strings.Index(got, "new.pdf") @@ -221,7 +222,7 @@ func TestList_StatusFilter_ForwardedToSDK(t *testing.T) { chdirIsolated(t) _, _ = iostreams.SetForTest(t) svc := &fakeListSvc{} - opts := &ListOptions{Page: 1, PageSize: 20, Status: "failed"} + opts := &ListOptions{PageSize: 20, Status: "failed"} require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx")) assert.Equal(t, "failed", svc.got.filter.ParseStatus, "--status must be forwarded as filter.ParseStatus for server-side filtering") @@ -231,7 +232,7 @@ func TestList_StatusFilter_RejectsUnknownValue(t *testing.T) { chdirIsolated(t) _, _ = iostreams.SetForTest(t) svc := &fakeListSvc{} - opts := &ListOptions{Page: 1, PageSize: 20, Status: "bogus"} + opts := &ListOptions{PageSize: 20, Status: "bogus"} err := runList(context.Background(), opts, nil, svc, "kb_xxx") require.Error(t, err) var typed *cmdutil.Error @@ -246,9 +247,101 @@ func TestList_StatusFilter_AcceptsAllEnumValues(t *testing.T) { for _, v := range docListStatusValues { _, _ = iostreams.SetForTest(t) svc := &fakeListSvc{} - opts := &ListOptions{Page: 1, PageSize: 20, Status: v} + opts := &ListOptions{PageSize: 20, Status: v} require.NoError(t, runList(context.Background(), opts, nil, svc, "kb_xxx"), "status=%q should be accepted", v) } } +// makeDocs returns N Knowledge records with distinct IDs and descending +// UpdatedAt timestamps, useful for limit / pagination tests. +func makeDocs(n int) []sdk.Knowledge { + base := time.Now() + out := make([]sdk.Knowledge, n) + for i := 0; i < n; i++ { + out[i] = sdk.Knowledge{ + ID: fmt.Sprintf("doc_%02d", i), + FileName: fmt.Sprintf("f_%02d.pdf", i), + UpdatedAt: base.Add(-time.Duration(i) * time.Hour), + } + } + return out +} + +// pagedDocSvc returns server-paginated Knowledge results from a flat slice. +// Records the page numbers requested for assertion. +type pagedDocSvc struct { + all []sdk.Knowledge + calls []int // 1-based page numbers received + pageSize int +} + +func (p *pagedDocSvc) ListKnowledgeWithFilter(_ context.Context, _ string, page, pageSize int, _ sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) { + p.calls = append(p.calls, page) + p.pageSize = pageSize + start := (page - 1) * pageSize + if start >= len(p.all) { + return []sdk.Knowledge{}, int64(len(p.all)), nil + } + end := start + pageSize + if end > len(p.all) { + end = len(p.all) + } + return p.all[start:end], int64(len(p.all)), nil +} + +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")) + body := out.String() + // Count occurrences of "id":"doc_" — should be exactly 5. + got := strings.Count(body, `"id":"doc_`) + assert.Equal(t, 5, got, "--limit 5 must slice 20 returned items to 5; body=\n%s", body) +} + +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")) + got := strings.Count(out.String(), `"id":"doc_`) + assert.Equal(t, 10, got, "--limit 50 with page-size 10 + 10 items returns all 10") +} + +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") + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code) +} + +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")) + // 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_`) + assert.Equal(t, 45, got) +} + +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")) + 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. + assert.LessOrEqual(t, len(svc.calls), 3, "should not walk past the page that fills --limit") +} + +// (test removed: --page flag was dropped in v0.4 mainstream-alignment; +// --all-pages now walks unconditionally, no mutex case to test.) + diff --git a/cli/cmd/doc/upload.go b/cli/cmd/doc/upload.go index 6705b38b..92b550a4 100644 --- a/cli/cmd/doc/upload.go +++ b/cli/cmd/doc/upload.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -125,7 +125,7 @@ Use --recursive --glob to upload a directory tree (see Examples).`, cmd.Flags().StringVar(&opts.Glob, "glob", "*", "Filename pattern to filter when --recursive (e.g. '*.pdf')") cmd.Flags().StringVar(&opts.FromURL, "from-url", "", "Ingest a remote `URL` (HTTP/HTTPS) instead of a local file") cmdutil.AddJSONFlags(cmd, docUploadFields) - agent.SetAgentHelp(cmd, "Uploads to the resolved KB. Default: one local regular file. With --recursive: walks the directory, filters by --glob, uploads each match. With --from-url: ingests a remote URL (mutually exclusive with the positional file / --recursive / --glob). Returns data: single Knowledge object (file or URL) or aggregate report (recursive). Errors: resource.already_exists when the URL was previously ingested.") + aiclient.SetAgentHelp(cmd, "Three modes (mutually exclusive): local file (positional), --recursive directory walk + --glob, or --from-url remote ingest. Returns data: Knowledge object (file/URL) or aggregate report (recursive). Errors: resource.already_exists (URL previously ingested) / local.upload_file_not_found.") return cmd } diff --git a/cli/cmd/doc/view.go b/cli/cmd/doc/view.go index 24d5bb2f..359c15e0 100644 --- a/cli/cmd/doc/view.go +++ b/cli/cmd/doc/view.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -53,7 +53,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { }, } cmdutil.AddJSONFlags(cmd, docViewFields) - agent.SetAgentHelp(cmd, "Returns the full Knowledge object for one document: id, kb_id, file metadata, parse_status, processed_at, error_message. Use this instead of paging through `doc list` when you have the doc id.") + aiclient.SetAgentHelp(cmd, "Returns the full Knowledge object for one document: id, kb_id, file metadata, parse_status, processed_at, error_message. Use this instead of paging through `doc list` when you have the doc id.") return cmd } diff --git a/cli/cmd/doctor/doctor.go b/cli/cmd/doctor/doctor.go index b4566f76..4fc28363 100644 --- a/cli/cmd/doctor/doctor.go +++ b/cli/cmd/doctor/doctor.go @@ -29,7 +29,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/build" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/compat" @@ -127,7 +127,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, "Bypass server-info cache (located at $XDG_CACHE_HOME/weknora/server-info.yaml); force re-probe") cmd.Flags().BoolVar(&opts.Offline, "offline", false, "Skip network checks; only verify local keyring/file storage (credential_storage check still runs)") cmdutil.AddJSONFlags(cmd, doctorFields) - agent.SetAgentHelp(cmd, "Returns 4 health checks. AGENT short-circuit: read data.summary.all_passed; if false, inspect data.checks[].status (ok/warn/fail/skip). exit 1 only when any status=fail; warn does not change envelope.ok.") + aiclient.SetAgentHelp(cmd, "Returns 4 health checks. AGENT short-circuit: read data.summary.all_passed; if false, inspect data.checks[].status (ok/warn/fail/skip). exit 1 only when any status=fail; warn does not change envelope.ok.") return cmd } diff --git a/cli/cmd/kb/create.go b/cli/cmd/kb/create.go index 4e8473ba..bf037461 100644 --- a/cli/cmd/kb/create.go +++ b/cli/cmd/kb/create.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -67,7 +67,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&opts.Description, "description", "", "Knowledge base description (optional)") cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model ID (optional; server picks default when unset)") cmdutil.AddJSONFlags(cmd, kbCreateFields) - agent.SetAgentHelp(cmd, "Creates a knowledge base under the active context. --name is required; --description and --embedding-model are optional. Returns data: full KnowledgeBase object including the new id.") + aiclient.SetAgentHelp(cmd, "Creates a knowledge base under the active context. --name is required; --description and --embedding-model are optional. Returns data: full KnowledgeBase object including the new id.") return cmd } diff --git a/cli/cmd/kb/delete.go b/cli/cmd/kb/delete.go index f63c9a9e..2fe91ec3 100644 --- a/cli/cmd/kb/delete.go +++ b/cli/cmd/kb/delete.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -73,7 +73,7 @@ guard against unintended deletes.`, }, } cmdutil.AddJSONFlags(cmd, kbDeleteFields) - agent.SetAgentHelp(cmd, "Destructively deletes a knowledge base by id. ALWAYS pass -y/--yes in agent mode (no TTY ⇒ confirm prompt fails). Returns data: {id, deleted:true}.") + aiclient.SetAgentHelp(cmd, "Destructively deletes a knowledge base by id. ALWAYS pass -y/--yes in agent mode (no TTY ⇒ confirm prompt fails). Returns data: {id, deleted:true}.") return cmd } diff --git a/cli/cmd/kb/edit.go b/cli/cmd/kb/edit.go index 5d6e3bc9..32852a07 100644 --- a/cli/cmd/kb/edit.go +++ b/cli/cmd/kb/edit.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -80,7 +80,7 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&name, "name", "", "New name (omit to leave unchanged)") cmd.Flags().StringVar(&desc, "description", "", "New description (omit to leave unchanged)") cmdutil.AddJSONFlags(cmd, kbEditFields) - agent.SetAgentHelp(cmd, "Edits a knowledge base. At least one of --name/--description is required. Fields not passed are preserved server-side. Returns the updated KnowledgeBase.") + aiclient.SetAgentHelp(cmd, "Edits a knowledge base. At least one of --name/--description is required. Fields not passed are preserved server-side. Returns the updated KnowledgeBase.") return cmd } diff --git a/cli/cmd/kb/empty.go b/cli/cmd/kb/empty.go index 327deea2..fad12d12 100644 --- a/cli/cmd/kb/empty.go +++ b/cli/cmd/kb/empty.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -69,7 +69,7 @@ piped contexts. Without -y the CLI exits 10 in non-interactive mode.`, }, } cmdutil.AddJSONFlags(cmd, kbEmptyFields) - agent.SetAgentHelp(cmd, "Destructively empties a knowledge base (deletes all documents, preserves the KB record + config). ALWAYS pass -y/--yes in agent mode (non-TTY ⇒ confirm fails). Returns data: {id, deleted_count}. Async — items may still be processing server-side after the call returns.") + aiclient.SetAgentHelp(cmd, "Destructively empties a knowledge base (deletes all documents, preserves the KB record + config). ALWAYS pass -y/--yes in agent mode (non-TTY ⇒ confirm fails). Returns data: {id, deleted_count}. Async — items may still be processing server-side after the call returns.") return cmd } diff --git a/cli/cmd/kb/list.go b/cli/cmd/kb/list.go index 78efe594..4342e44e 100644 --- a/cli/cmd/kb/list.go +++ b/cli/cmd/kb/list.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -33,6 +33,10 @@ var kbListFields = []string{ // ListOptions captures `kb list` filter flag state. type ListOptions struct { Pinned bool // --pinned: client-side filter to KBs with IsPinned == true + // Limit caps the returned slice client-side. 0 = no cap, 1..10000 = explicit. + // The KB list SDK is unpaginated; --all-pages is intentionally not exposed + // because it would be a no-op. + Limit int } // ListService is the narrow SDK surface this command depends on. @@ -65,12 +69,19 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { }, } 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) - agent.SetAgentHelp(cmd, "Lists all knowledge bases. Returns data.items: [{id, name, ...}]; empty array when none. --pinned restricts to pinned KBs (client-side filter). Use `--json` (bare) for the field list, `--json id,name` to project, or `--jq` for arbitrary reshape.") + aiclient.SetAgentHelp(cmd, "Lists all knowledge bases. Returns data.items: [{id, name, ...}]; empty array when none. --pinned restricts to pinned KBs (client-side filter). --limit caps the returned slice. Use `--json` (bare) for the field list, `--json id,name` to project, or `--jq` for arbitrary reshape.") return cmd } func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, svc ListService) error { + if opts.Limit < 0 || opts.Limit > 10000 { + return &cmdutil.Error{ + Code: cmdutil.CodeInputInvalidArgument, + Message: fmt.Sprintf("--limit must be in 0..10000 (0 = no cap), got %d", opts.Limit), + } + } items, err := svc.ListKnowledgeBases(ctx) if err != nil { return cmdutil.WrapHTTP(err, "list knowledge bases") @@ -93,6 +104,10 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) }) + // --limit applies after sort so the cap returns the top-N most-recent. + if opts.Limit > 0 && len(items) > opts.Limit { + items = items[:opts.Limit] + } if jopts.Enabled() { return format.WriteEnvelopeFiltered( diff --git a/cli/cmd/kb/list_test.go b/cli/cmd/kb/list_test.go index 131131e4..7b7acf9b 100644 --- a/cli/cmd/kb/list_test.go +++ b/cli/cmd/kb/list_test.go @@ -3,6 +3,8 @@ package kb import ( "context" "encoding/json" + "errors" + "fmt" "strings" "testing" "time" @@ -143,3 +145,58 @@ func TestList_PinnedFilter_NoPinned_HumanMessage(t *testing.T) { t.Errorf("expected pinned-specific empty message, got: %q", out.String()) } } + +// makeKBs returns N KBs with distinct IDs and descending UpdatedAt. +func makeKBs(n int) []sdk.KnowledgeBase { + base := time.Now() + out := make([]sdk.KnowledgeBase, n) + for i := 0; i < n; i++ { + out[i] = sdk.KnowledgeBase{ + ID: fmt.Sprintf("kb_%02d", i), + Name: fmt.Sprintf("kb-%02d", i), + UpdatedAt: base.Add(-time.Duration(i) * time.Hour), + } + } + return out +} + +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 { + t.Fatalf("runList: %v", err) + } + got := strings.Count(out.String(), `"id":"kb_`) + if got != 5 { + t.Errorf("--limit 5 should slice 20 items to 5; got %d in:\n%s", got, out.String()) + } +} + +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 { + t.Fatalf("runList: %v", err) + } + got := strings.Count(out.String(), `"id":"kb_`) + if got != 7 { + t.Errorf("--limit 0 must not cap; got %d, want 7", got) + } +} + +func TestList_Limit_Negative_Rejected(t *testing.T) { + _, _ = iostreams.SetForTest(t) + err := runList(context.Background(), &ListOptions{Limit: -1}, nil, &fakeListSvc{items: makeKBs(3)}) + if err == nil { + t.Fatal("expected error for negative --limit") + } + var typed *cmdutil.Error + if !errors.As(err, &typed) { + t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err) + } + if typed.Code != cmdutil.CodeInputInvalidArgument { + t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code) + } +} diff --git a/cli/cmd/kb/pin.go b/cli/cmd/kb/pin.go index 6d1dd4b8..636b98ed 100644 --- a/cli/cmd/kb/pin.go +++ b/cli/cmd/kb/pin.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -63,7 +63,7 @@ func newPinCmd(f *cmdutil.Factory, use string, want bool, short string) *cobra.C }, } cmdutil.AddJSONFlags(cmd, kbPinFields) - agent.SetAgentHelp(cmd, fmt.Sprintf("Idempotent %s: reads current pin state, toggles only if different. No-op when already in the requested state.", use)) + aiclient.SetAgentHelp(cmd, fmt.Sprintf("Idempotent %s: reads current pin state, toggles only if different. No-op when already in the requested state.", use)) return cmd } diff --git a/cli/cmd/kb/view.go b/cli/cmd/kb/view.go index 41d1bdd9..89f0eed2 100644 --- a/cli/cmd/kb/view.go +++ b/cli/cmd/kb/view.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -53,7 +53,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { }, } cmdutil.AddJSONFlags(cmd, kbViewFields) - agent.SetAgentHelp(cmd, "Returns details of one knowledge base by ID (config + counts).") + aiclient.SetAgentHelp(cmd, "Returns details of one knowledge base by ID (config + counts).") return cmd } diff --git a/cli/cmd/link/link.go b/cli/cmd/link/link.go index 53256385..451fc4b7 100644 --- a/cli/cmd/link/link.go +++ b/cli/cmd/link/link.go @@ -14,7 +14,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -68,7 +68,7 @@ user explicitly asked to bind this directory; don't run it as a side effect.`, } cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name; omit on a TTY for interactive prompt") cmdutil.AddJSONFlags(cmd, linkFields) - agent.SetAgentHelp(cmd, "Writes .weknora/project.yaml binding cwd to a KB. Pass --kb (id or name) for non-interactive use. Always overwrites.") + aiclient.SetAgentHelp(cmd, "Writes .weknora/project.yaml binding cwd to a KB. Pass --kb (id or name) for non-interactive use. Always overwrites.") return cmd } diff --git a/cli/cmd/link/unlink.go b/cli/cmd/link/unlink.go index 9ca23e81..dbdac27e 100644 --- a/cli/cmd/link/unlink.go +++ b/cli/cmd/link/unlink.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -50,7 +50,7 @@ is present anywhere in the parent chain.`, }, } cmdutil.AddJSONFlags(cmd, unlinkFields) - agent.SetAgentHelp(cmd, "Removes .weknora/project.yaml (the cwd → KB binding). Walks up from cwd. Errors with input.invalid_argument when no link is present in the parent chain.") + aiclient.SetAgentHelp(cmd, "Removes .weknora/project.yaml (the cwd → KB binding). Walks up from cwd. Errors with input.invalid_argument when no link is present in the parent chain.") return cmd } diff --git a/cli/cmd/mcp/mcp.go b/cli/cmd/mcp/mcp.go index 4f67e2b7..a2ec1ea9 100644 --- a/cli/cmd/mcp/mcp.go +++ b/cli/cmd/mcp/mcp.go @@ -1,12 +1,11 @@ // Package mcpcmd holds the `weknora mcp` command tree. // -// MCP (Model Context Protocol; https://spec.modelcontextprotocol.io/) is the -// JSON-RPC 2.0 wire protocol agentic IDEs (Claude Code, Cursor, Continue, -// Zed) and runtimes (Anthropic Reference, Stripe MCP) use to call external -// tools. `weknora mcp serve` exposes a curated subset of the CLI's read -// surface as MCP tools so an IDE-side agent can list / view / search / chat -// against the user's active WeKnora context without shelling out to the CLI -// per call. +// MCP (Model Context Protocol; https://spec.modelcontextprotocol.io/) is +// the JSON-RPC 2.0 wire protocol agentic IDEs use to call external tools. +// `weknora mcp serve` exposes a curated subset of the CLI's read surface +// as MCP tools so an IDE-side agent can list / view / search / chat against +// the user's active WeKnora context without shelling out to the CLI per +// call. // // Package name is `mcpcmd` to avoid shadowing `cli/internal/mcp` (the // transport-and-handlers implementation). Same naming hygiene as diff --git a/cli/cmd/mcp/serve.go b/cli/cmd/mcp/serve.go index 54118e2e..eb57bfbf 100644 --- a/cli/cmd/mcp/serve.go +++ b/cli/cmd/mcp/serve.go @@ -3,7 +3,7 @@ package mcpcmd import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" mcpserver "github.com/Tencent/WeKnora/cli/internal/mcp" ) @@ -42,6 +42,6 @@ To use with Claude Code, add to ~/.claude/mcp_servers.json: return mcpserver.RunStdio(c.Context(), cli) }, } - agent.SetAgentHelp(cmd, "Long-lived stdio MCP server. Reads JSON-RPC requests from stdin, writes responses to stdout, logs to stderr. Surfaces 9 read-only tools (kb_list/kb_view/doc_list/doc_view/doc_download/search_chunks/chat/agent_list/agent_invoke); destructive verbs are intentionally excluded. Auth is inherited from the active context — to switch, exit and re-launch with --context. Long tools (chat / agent_invoke) accumulate the LLM stream server-side and return a single CallToolResult — no MCP streaming-content extension, per spec 2025-06-18 (tools.mdx).") + aiclient.SetAgentHelp(cmd, "Long-lived stdio MCP server. stdin/stdout for JSON-RPC, stderr for logs. Exposes 9 read-only tools; destructive verbs intentionally excluded. Auth inherited from active context — exit and relaunch with --context to switch. chat / agent_invoke accumulate server-side and return a single result (MCP spec has no streaming-content extension).") return cmd } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 7431060f..fda298a1 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -20,7 +20,7 @@ import ( mcpcmd "github.com/Tencent/WeKnora/cli/cmd/mcp" "github.com/Tencent/WeKnora/cli/cmd/search" sessioncmd "github.com/Tencent/WeKnora/cli/cmd/session" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/build" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" @@ -58,8 +58,8 @@ func Execute() int { // Exported so the acceptance/contract test helper can replicate Execute()'s // envelope-printing path without having to call os.Exit-bound Execute() itself. func WantsJSONOutput(cmd *cobra.Command) bool { - // --json is StringSlice in v0.4 (gh-style field filter). Non-empty - // slice OR a "Changed=true" flag indicates the user requested JSON. + // --json is a StringSlice in v0.4 (optional field filter). A + // Changed=true flag indicates the user requested JSON output. if f := cmd.Flags().Lookup("json"); f != nil && f.Changed { return true } @@ -193,16 +193,16 @@ func addGlobalFlags(cmd *cobra.Command) { } // agentAwareHelpFunc wraps cobra's default help to append the AI agent guidance -// (Annotations[agent.AIAgentHelpKey]) only when an AI coding agent env var is +// (Annotations[aiclient.AIAgentHelpKey]) only when an AI coding agent env var is // detected (CLAUDECODE / CURSOR_AGENT). Help-only render — no behavior switch // (v0.2 ADR-3). func agentAwareHelpFunc(orig func(*cobra.Command, []string)) func(*cobra.Command, []string) { return func(c *cobra.Command, args []string) { orig(c, args) - if agent.DetectAIAgent() == "" { + if aiclient.DetectAIAgent() == "" { return } - extra := agent.FormatAgentGuidance(c) + extra := aiclient.FormatAgentGuidance(c) if extra == "" { return } diff --git a/cli/cmd/search/chunks.go b/cli/cmd/search/chunks.go index c312c823..e264fa4b 100644 --- a/cli/cmd/search/chunks.go +++ b/cli/cmd/search/chunks.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -85,7 +85,7 @@ func NewCmdChunks(f *cmdutil.Factory) *cobra.Command { } bindChunksFlags(cmd, opts) _ = cmd.MarkFlagRequired("kb") - agent.SetAgentHelp(cmd, "Hybrid retrieval; returns ranked chunk list. The server may include parent/nearby/relation chunks beyond match_count; --limit caps the returned slice client-side. Pass --no-vector or --no-keyword to disable a channel (mutually exclusive both-off).") + aiclient.SetAgentHelp(cmd, "Hybrid retrieval; returns ranked chunk list. The server may include parent/nearby/relation chunks beyond match_count; --limit caps the returned slice client-side. Pass --no-vector or --no-keyword to disable a channel (mutually exclusive both-off).") return cmd } @@ -93,7 +93,7 @@ func NewCmdChunks(f *cmdutil.Factory) *cobra.Command { // the constructor readable; --kb is marked required by the caller. func bindChunksFlags(cmd *cobra.Command, opts *ChunksOptions) { cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name") - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 8, "Maximum results to return") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 8, "Maximum results to return (default 8 — tuned for RAG context window; list commands default to 30)") cmd.Flags().Float64Var(&opts.VectorThreshold, "vector-threshold", 0, "Vector retrieval similarity floor (per-channel, pre-fusion); 0 = no filter") 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") diff --git a/cli/cmd/search/docs.go b/cli/cmd/search/docs.go index c97cd63c..3f75ef0b 100644 --- a/cli/cmd/search/docs.go +++ b/cli/cmd/search/docs.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -88,10 +88,10 @@ func NewCmdDocs(f *cmdutil.Factory) *cobra.Command { }, } cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name (required)") - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 20, "Maximum results to return") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return") cmdutil.AddJSONFlags(cmd, docsFields) _ = cmd.MarkFlagRequired("kb") - agent.SetAgentHelp(cmd, "Lists documents in --kb whose title or file_name contains the query. Pages through the KB sequentially; stops once limit hits found. Returns the full Knowledge object so agents can derive id / file_size / processed_at without a second call.") + aiclient.SetAgentHelp(cmd, "Lists documents in --kb whose title or file_name contains the query. Pages through the KB sequentially; stops once limit hits found. Returns the full Knowledge object so agents can derive id / file_size / processed_at without a second call.") return cmd } diff --git a/cli/cmd/search/kb.go b/cli/cmd/search/kb.go index 428a5ee2..28877539 100644 --- a/cli/cmd/search/kb.go +++ b/cli/cmd/search/kb.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -76,9 +76,9 @@ func NewCmdKB(f *cmdutil.Factory) *cobra.Command { return runKBSearch(c.Context(), opts, jopts, cli) }, } - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 20, "Maximum results to return") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return") cmdutil.AddJSONFlags(cmd, kbSearchFields) - agent.SetAgentHelp(cmd, "Lists KBs whose name or description contains the query (case-insensitive). Useful to discover --kb identifiers before running search chunks / doc list.") + aiclient.SetAgentHelp(cmd, "Lists KBs whose name or description contains the query (case-insensitive). Useful to discover --kb identifiers before running search chunks / doc list.") return cmd } diff --git a/cli/cmd/search/search.go b/cli/cmd/search/search.go index 0dec82ea..a9f02a5b 100644 --- a/cli/cmd/search/search.go +++ b/cli/cmd/search/search.go @@ -5,7 +5,7 @@ package search import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" ) @@ -28,7 +28,7 @@ func NewCmdSearch(f *cmdutil.Factory) *cobra.Command { Args: cobra.NoArgs, Run: func(c *cobra.Command, _ []string) { _ = c.Help() }, } - agent.SetAgentHelp(cmd, "Search parent. Use `search chunks/kb/docs/sessions ` — there is no bare-positional form.") + aiclient.SetAgentHelp(cmd, "Search parent. Use `search chunks/kb/docs/sessions ` — there is no bare-positional form.") cmd.AddCommand(NewCmdChunks(f)) cmd.AddCommand(NewCmdKB(f)) diff --git a/cli/cmd/search/sessions.go b/cli/cmd/search/sessions.go index 4a76ecae..7348f8ec 100644 --- a/cli/cmd/search/sessions.go +++ b/cli/cmd/search/sessions.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -71,9 +71,9 @@ func NewCmdSessions(f *cmdutil.Factory) *cobra.Command { return runSessionsSearch(c.Context(), opts, jopts, cli) }, } - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 20, "Maximum results to return") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return") cmdutil.AddJSONFlags(cmd, sessionsSearchFields) - agent.SetAgentHelp(cmd, "Lists chat sessions whose title or description contains the query. Pages through the tenant sequentially; stops once limit matches found. Returns full Session objects so agents can pivot to session view/delete by id.") + aiclient.SetAgentHelp(cmd, "Lists chat sessions whose title or description contains the query. Pages through the tenant sequentially; stops once limit matches found. Returns full Session objects so agents can pivot to session view/delete by id.") return cmd } diff --git a/cli/cmd/session/delete.go b/cli/cmd/session/delete.go index de7e84ba..5bdb0a85 100644 --- a/cli/cmd/session/delete.go +++ b/cli/cmd/session/delete.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -70,7 +70,7 @@ without the user's explicit go-ahead.`, }, } cmdutil.AddJSONFlags(cmd, sessionDeleteFields) - agent.SetAgentHelp(cmd, "Destructively deletes a chat session by id. ALWAYS pass -y/--yes in agent mode (no TTY ⇒ confirm prompt fails). Returns data: {id, deleted:true}.") + aiclient.SetAgentHelp(cmd, "Destructively deletes a chat session by id. ALWAYS pass -y/--yes in agent mode (no TTY ⇒ confirm prompt fails). Returns data: {id, deleted:true}.") return cmd } diff --git a/cli/cmd/session/list.go b/cli/cmd/session/list.go index 4b943783..5334a014 100644 --- a/cli/cmd/session/list.go +++ b/cli/cmd/session/list.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -31,9 +31,14 @@ var sessionListFields = []string{ } type ListOptions struct { - Page int - PageSize int + PageSize int // Items per server batch (default 50). Since string // --since: filter to sessions updated within the past duration + // Limit caps the returned items client-side (default 30; 0 = no cap). + // Applied after pagination / --all-pages accumulation and --since filter. + Limit int + // AllPages walks server pages internally, accumulating items until + // total exhausted or --limit hit. + AllPages bool } // ListService is the narrow SDK surface this command depends on. @@ -46,10 +51,9 @@ type listResult struct { Items []sdk.Session `json:"items"` } -// NewCmdList builds `weknora session list`. Paginated; defaults to page=1 -// page_size=30. No cursor — server is page-based today. +// NewCmdList builds `weknora session list`. func NewCmdList(f *cmdutil.Factory) *cobra.Command { - opts := &ListOptions{Page: defaultPage, PageSize: defaultPageSize} + opts := &ListOptions{PageSize: defaultPageSize} cmd := &cobra.Command{ Use: "list", Short: "List chat sessions for the active context", @@ -66,27 +70,28 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return runList(c.Context(), opts, jopts, cli) }, } - cmd.Flags().IntVar(&opts.Page, "page", defaultPage, "Page number (1-indexed)") - cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per page (1..1000)") + cmd.Flags().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) - agent.SetAgentHelp(cmd, "Lists chat sessions. _meta.has_more is set when more pages exist; bump --page and retry to walk them. --since filters *within the returned page* (client-side); widen --page-size when looking far back.") + aiclient.SetAgentHelp(cmd, "Lists chat sessions. data.{items}; pagination metadata in _meta.{page, page_size, total, has_more}. --all-pages drains every server page in one call (capped by --limit). --since filters client-side after fetch.") return cmd } func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, svc ListService) error { - if opts.Page < 1 { - return &cmdutil.Error{ - Code: cmdutil.CodeInputInvalidArgument, - Message: fmt.Sprintf("--page must be >= 1, got %d", opts.Page), - } - } if opts.PageSize < 1 || opts.PageSize > maxPageSize { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, Message: fmt.Sprintf("--page-size must be in 1..%d, got %d", maxPageSize, opts.PageSize), } } + if opts.Limit < 0 || opts.Limit > 10000 { + return &cmdutil.Error{ + Code: cmdutil.CodeInputInvalidArgument, + Message: fmt.Sprintf("--limit must be in 0..10000 (0 = no cap), got %d", opts.Limit), + } + } var since time.Duration if opts.Since != "" { d, err := parseSinceDuration(opts.Since) @@ -96,9 +101,37 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, since = d } - items, total, err := svc.GetSessionsByTenant(ctx, opts.Page, opts.PageSize) - if err != nil { - return cmdutil.WrapHTTP(err, "list sessions") + var ( + items []sdk.Session + total int + ) + if opts.AllPages { + accum := make([]sdk.Session, 0) + page := 1 + for { + chunk, t, err := svc.GetSessionsByTenant(ctx, page, opts.PageSize) + if err != nil { + return cmdutil.WrapHTTP(err, "list sessions") + } + total = t + accum = append(accum, chunk...) + if opts.Limit > 0 && len(accum) >= opts.Limit { + accum = accum[:opts.Limit] + break + } + if page*opts.PageSize >= total || len(chunk) == 0 { + break + } + page++ + } + items = accum + } else { + chunk, t, err := svc.GetSessionsByTenant(ctx, 1, opts.PageSize) + if err != nil { + return cmdutil.WrapHTTP(err, "list sessions") + } + items = chunk + total = t } if items == nil { items = []sdk.Session{} // JSON [] not null @@ -117,16 +150,21 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, } items = filtered } + // --limit applies after --since so the cap reflects what the user sees. + if opts.Limit > 0 && len(items) > opts.Limit { + items = items[:opts.Limit] + } if jopts.Enabled() { - // When --since is active, has_more is meaningless: the server's - // total counts all sessions but the page is now client-side - // filtered. An agent walking pages would see has_more=true even - // when no later page can contain matching items. Drop the flag - // rather than mislead. The agent_help string documents this. - meta := &format.Meta{} - if since == 0 { - meta.HasMore = opts.Page*opts.PageSize < total + // has_more is suppressed when --since filter is active (server + // total ≠ what we returned) or when --all-pages drained the server. + meta := &format.Meta{ + Page: 1, + PageSize: opts.PageSize, + Total: int64(total), + } + if since == 0 && !opts.AllPages { + meta.HasMore = opts.PageSize < total } return format.WriteEnvelopeFiltered( iostreams.IO.Out, diff --git a/cli/cmd/session/list_test.go b/cli/cmd/session/list_test.go index f1c63d31..f4cd2cf8 100644 --- a/cli/cmd/session/list_test.go +++ b/cli/cmd/session/list_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" "testing" "time" @@ -35,7 +36,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{Page: 1, PageSize: 30}, nil, svc)) + require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, nil, svc)) assert.Contains(t, out.String(), "no sessions") } @@ -48,7 +49,7 @@ func TestList_Table(t *testing.T) { }, total: 2, } - require.NoError(t, runList(context.Background(), &ListOptions{Page: 1, PageSize: 30}, nil, svc)) + require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, nil, svc)) got := out.String() assert.Contains(t, got, "s_1") assert.Contains(t, got, "Design review") @@ -65,30 +66,34 @@ func TestList_JSON_WithMeta(t *testing.T) { }, total: 47, } - require.NoError(t, runList(context.Background(), &ListOptions{Page: 2, PageSize: 10}, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 10}, &cmdutil.JSONOptions{}, svc)) var env format.Envelope require.NoError(t, json.Unmarshal(out.Bytes(), &env)) require.True(t, env.OK) - // Pagination flags are forwarded. - assert.Equal(t, 2, svc.gotPage) + // Pagination is server-internal: CLI always asks for page 1 of size --page-size. + assert.Equal(t, 1, svc.gotPage) assert.Equal(t, 10, svc.gotPageSize) // envelope.data.items shaped + paging metadata in _meta body := out.String() assert.Contains(t, body, `"id":"s_1"`) assert.Contains(t, body, `"items":`) - // has_more inferred from page*pageSize < total (2*10=20 < 47). + // has_more inferred from page_size < total (10 < 47). assert.Contains(t, body, `"has_more":true`) + // pagination metadata lives in _meta now + assert.Contains(t, body, `"page":1`) + assert.Contains(t, body, `"page_size":10`) + assert.Contains(t, body, `"total":47`) } -func TestList_JSON_LastPage_NoHasMore(t *testing.T) { +func TestList_JSON_PageSizeCoversAll_NoHasMore(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeListService{ items: []sdk.Session{{ID: "s_1"}}, - total: 11, + total: 1, } - require.NoError(t, runList(context.Background(), &ListOptions{Page: 2, PageSize: 10}, &cmdutil.JSONOptions{}, svc)) - // page*size = 20 ≥ total 11 → has_more must be false (omitempty drops the key) + require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, &cmdutil.JSONOptions{}, svc)) + // page_size (30) ≥ total (1) → has_more must be false (omitempty drops the key) body := out.String() assert.NotContains(t, body, `"has_more":true`) } @@ -96,24 +101,22 @@ func TestList_JSON_LastPage_NoHasMore(t *testing.T) { func TestList_NilItems_RendersAsEmptyArray(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeListService{items: nil, total: 0} - require.NoError(t, runList(context.Background(), &ListOptions{Page: 1, PageSize: 30}, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, &cmdutil.JSONOptions{}, svc)) assert.Contains(t, out.String(), `"items":[]`) } func TestList_BadPagination(t *testing.T) { _, _ = iostreams.SetForTest(t) cases := []struct { - page, size int - name string + size int + name string }{ - {0, 30, "page < 1"}, - {-1, 30, "page negative"}, - {1, 0, "size < 1"}, - {1, 1001, "size > max"}, + {0, "size < 1"}, + {1001, "size > max"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := runList(context.Background(), &ListOptions{Page: tc.page, PageSize: tc.size}, nil, &fakeListService{}) + err := runList(context.Background(), &ListOptions{PageSize: tc.size}, nil, &fakeListService{}) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -125,7 +128,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{Page: 1, PageSize: 30}, nil, svc) + err := runList(context.Background(), &ListOptions{PageSize: 30}, nil, svc) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -136,7 +139,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{Page: 1, PageSize: 30}, nil, svc)) + require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, nil, svc)) assert.Contains(t, out.String(), "s_zh") } @@ -149,7 +152,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{Page: 1, PageSize: 30, Since: "7d"}, nil, + &ListOptions{PageSize: 30, Since: "7d"}, nil, &fakeListService{items: items, total: 3})) got := out.String() assert.Contains(t, got, "recent") @@ -163,7 +166,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{Page: 1, PageSize: 30, Since: v}, nil, + &ListOptions{PageSize: 30, Since: v}, nil, &fakeListService{items: []sdk.Session{}, total: 0}), "--since=%q should parse", v) }) @@ -173,7 +176,7 @@ func TestList_SinceFilter_ParseDuration_Variants(t *testing.T) { func TestList_SinceFilter_RejectsInvalidDuration(t *testing.T) { _, _ = iostreams.SetForTest(t) err := runList(context.Background(), - &ListOptions{Page: 1, PageSize: 30, Since: "bogus"}, nil, + &ListOptions{PageSize: 30, Since: "bogus"}, nil, &fakeListService{items: []sdk.Session{}, total: 0}) require.Error(t, err) var typed *cmdutil.Error @@ -185,7 +188,7 @@ func TestList_SinceFilter_RejectsNonPositive(t *testing.T) { _, _ = iostreams.SetForTest(t) for _, v := range []string{"0d", "0h", "-1h"} { err := runList(context.Background(), - &ListOptions{Page: 1, PageSize: 30, Since: v}, nil, + &ListOptions{PageSize: 30, Since: v}, nil, &fakeListService{items: []sdk.Session{}, total: 0}) require.Error(t, err, "--since=%q should reject", v) var typed *cmdutil.Error @@ -193,3 +196,93 @@ func TestList_SinceFilter_RejectsNonPositive(t *testing.T) { assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code) } } + +// makeSessions builds N sessions with distinct IDs and descending UpdatedAt. +func makeSessions(n int) []sdk.Session { + base := time.Now() + out := make([]sdk.Session, n) + for i := 0; i < n; i++ { + out[i] = sdk.Session{ + ID: fmt.Sprintf("s_%03d", i), + Title: fmt.Sprintf("title-%03d", i), + UpdatedAt: base.Add(-time.Duration(i) * time.Minute).Format(time.RFC3339), + } + } + return out +} + +// pagedSessionSvc returns server-paginated session results from a flat slice +// and records page numbers requested. +type pagedSessionSvc struct { + all []sdk.Session + calls []int + pageSize int +} + +func (p *pagedSessionSvc) GetSessionsByTenant(_ context.Context, page, pageSize int) ([]sdk.Session, int, error) { + p.calls = append(p.calls, page) + p.pageSize = pageSize + start := (page - 1) * pageSize + if start >= len(p.all) { + return []sdk.Session{}, len(p.all), nil + } + end := start + pageSize + if end > len(p.all) { + end = len(p.all) + } + return p.all[start:end], len(p.all), nil +} + +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)) + got := strings.Count(out.String(), `"id":"s_`) + assert.Equal(t, 5, got, "--limit 5 must slice 20 items down to 5") +} + +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)) + got := strings.Count(out.String(), `"id":"s_`) + assert.Equal(t, 10, got) +} + +func TestList_Limit_Negative_Rejected(t *testing.T) { + _, _ = iostreams.SetForTest(t) + err := runList(context.Background(), + &ListOptions{PageSize: 30, Limit: -1}, nil, + &fakeListService{}) + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code) +} + +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)) + assert.Equal(t, []int{1, 2, 3}, svc.calls) + got := strings.Count(out.String(), `"id":"s_`) + assert.Equal(t, 45, got) + // --all-pages drained, so has_more should be absent. + assert.NotContains(t, out.String(), `"has_more":true`) +} + +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)) + 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") +} + +// (test removed: --page flag was dropped in v0.4 mainstream-alignment; +// --all-pages now walks unconditionally, no mutex case to test.) diff --git a/cli/cmd/session/view.go b/cli/cmd/session/view.go index 72ce7545..c5520792 100644 --- a/cli/cmd/session/view.go +++ b/cli/cmd/session/view.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/Tencent/WeKnora/cli/internal/agent" + "github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/format" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -50,7 +50,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { }, } cmdutil.AddJSONFlags(cmd, sessionViewFields) - agent.SetAgentHelp(cmd, "Shows a chat session's metadata (title, description, timestamps). Errors with resource.not_found if id is unknown.") + aiclient.SetAgentHelp(cmd, "Shows a chat session's metadata (title, description, timestamps). Errors with resource.not_found if id is unknown.") return cmd } diff --git a/cli/go.mod b/cli/go.mod index c1ef376c..48f02ca1 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,6 +1,6 @@ module github.com/Tencent/WeKnora/cli -go 1.25.0 +go 1.26.0 require ( github.com/Tencent/WeKnora/client v0.0.0-00010101000000-000000000000 diff --git a/cli/internal/agent/annotations.go b/cli/internal/aiclient/annotations.go similarity index 98% rename from cli/internal/agent/annotations.go rename to cli/internal/aiclient/annotations.go index 4428ef0c..001fe85c 100644 --- a/cli/internal/agent/annotations.go +++ b/cli/internal/aiclient/annotations.go @@ -1,4 +1,4 @@ -package agent +package aiclient import ( "strings" diff --git a/cli/internal/agent/detect.go b/cli/internal/aiclient/detect.go similarity index 98% rename from cli/internal/agent/detect.go rename to cli/internal/aiclient/detect.go index e7cc285c..bb4cc6af 100644 --- a/cli/internal/agent/detect.go +++ b/cli/internal/aiclient/detect.go @@ -7,7 +7,7 @@ // // What remains here: a small env-detect for known coding agents, used purely // to render the AGENT-targeted help section (no behavior change). -package agent +package aiclient import "os" diff --git a/cli/internal/agent/detect_test.go b/cli/internal/aiclient/detect_test.go similarity index 99% rename from cli/internal/agent/detect_test.go rename to cli/internal/aiclient/detect_test.go index 8abac502..301a4b77 100644 --- a/cli/internal/agent/detect_test.go +++ b/cli/internal/aiclient/detect_test.go @@ -1,4 +1,4 @@ -package agent +package aiclient import ( "testing" diff --git a/cli/internal/cmdutil/jsonflags.go b/cli/internal/cmdutil/jsonflags.go index d127154a..814e0b7c 100644 --- a/cli/internal/cmdutil/jsonflags.go +++ b/cli/internal/cmdutil/jsonflags.go @@ -26,12 +26,11 @@ func (o *JSONOptions) Enabled() bool { return o != nil } // after it). pflag's NoOptDefVal mechanism stores this sentinel into the // slice; CheckJSONFlags then maps it to "no field filter" (full envelope). // -// This is a documented divergence from gh CLI, where bare `--json` errors -// with a field-list discovery prompt. weknora's envelope is itself the -// machine-readable contract (carries typed error.code / _meta / risk), -// so `--json` bare always producing the full envelope keeps v0.3 caller -// scripts and the typical agent pipe-to-jq pattern working. Field -// discovery moves to per-command `--help` "JSON fields:" sections. +// Rationale: WeKnora's envelope is itself the machine-readable contract +// (carries typed error.code / _meta / risk), so a bare `--json` always +// producing the full envelope keeps the standard `weknora kb list --json | +// jq` pattern working. Field discovery moves to per-command `--help` +// "JSON fields available" sections rendered by AddJSONFlags. const jsonNoOptSentinel = "\x00json-no-value" // AddJSONFlags registers --json and --jq on cmd. @@ -74,10 +73,6 @@ func AddJSONFlags(cmd *cobra.Command, fields []string) { // // Bare `--json` yields Fields == nil (full envelope). Explicit field list // yields Fields == []string{"id", "name", ...} (filter applied). -// -// gh divergence: gh errors on bare --json with a discovery prompt. weknora -// treats bare --json as "full envelope, no filter" because the envelope is -// the contract. func CheckJSONFlags(cmd *cobra.Command) (*JSONOptions, error) { f := cmd.Flags() jsonFlag := f.Lookup("json") @@ -105,7 +100,8 @@ func CheckJSONFlags(cmd *cobra.Command) (*JSONOptions, error) { return opts, nil } if jqFlag != nil && jqFlag.Changed { - // gh parity: plain error, exit 1 (not FlagError → exit 2). + // Plain error, exit 1 (not FlagError → exit 2): the flag itself + // parsed fine, the combination is what's wrong. return nil, errors.New("cannot use `--jq` without specifying `--json`") } return nil, nil diff --git a/cli/internal/format/envelope.go b/cli/internal/format/envelope.go index 588b3678..10bdc507 100644 --- a/cli/internal/format/envelope.go +++ b/cli/internal/format/envelope.go @@ -62,6 +62,11 @@ type Risk struct { } // Meta carries non-payload context fields useful to agents and observability. +// +// Pagination metadata (Page / PageSize / Total) lives here rather than in +// data.{...} so every list command's `data` field has the same shape — +// always `{items: [...]}` — and agents can branch on the resource type +// without per-list parser variants. type Meta struct { Context string `json:"context,omitempty"` TenantID uint64 `json:"tenant_id,omitempty"` @@ -69,6 +74,9 @@ type Meta struct { RequestID string `json:"request_id,omitempty"` NextCursor string `json:"next_cursor,omitempty"` HasMore bool `json:"has_more,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + Total int64 `json:"total,omitempty"` Warnings []string `json:"warnings,omitempty"` AppliedFilters []string `json:"applied_filters,omitempty"` } diff --git a/cli/internal/format/filter.go b/cli/internal/format/filter.go index 78bcca3a..0fd1c2a8 100644 --- a/cli/internal/format/filter.go +++ b/cli/internal/format/filter.go @@ -60,8 +60,9 @@ func marshalEnvelope(env Envelope) ([]byte, error) { // defensive): filter each [*]. // - data is nil / scalar: unchanged. // -// Unknown field names are silently ignored (consistent with gh — users may -// pass an aspirational field set across heterogenous list outputs). +// Unknown field names are silently ignored so a user may pass an +// aspirational field set across heterogenous list outputs without per- +// command tailoring. func applyFieldFilter(envelopeJSON []byte, fields []string) ([]byte, error) { var raw map[string]json.RawMessage if err := json.Unmarshal(envelopeJSON, &raw); err != nil { @@ -160,9 +161,10 @@ func filterObjectKeys(objRaw json.RawMessage, fields []string) (json.RawMessage, return json.Marshal(dst) } -// writeJQ evaluates expr against envelopeJSON and writes each result line by -// line to w. Mirrors gh's pkg/export/filter.go semantics: results that are -// strings render without quotes; everything else uses encoding/json. +// writeJQ evaluates expr against envelopeJSON and writes each result line +// by line to w. String results render without quotes (so `--jq '.x.name'` +// yields shell-friendly bare strings); non-string results use +// encoding/json. // // Returns input.invalid_argument-shaped errors via plain errors.New + fmt; // the caller is responsible for wrapping with cmdutil.NewError if it wants diff --git a/cli/internal/mcp/server.go b/cli/internal/mcp/server.go index ad99aee8..f6fa249f 100644 --- a/cli/internal/mcp/server.go +++ b/cli/internal/mcp/server.go @@ -9,9 +9,8 @@ // don't belong on an agent-callable surface). // - Long-running tools (chat / agent_invoke) accumulate the LLM SSE // stream server-side and return a single CallToolResult — MCP spec -// 2025-06-18 does not define streamed tool-result content, so this is -// the canonical pattern (see Anthropic reference `everything` server -// and Stripe @stripe/mcp). +// 2025-06-18 does not define streamed tool-result content, so the +// accumulate-and-return pattern is the canonical path. // - Handlers receive ctx for cancellation; mid-LLM-stream cancellation // propagates to the SDK via context, which closes the SSE connection. package mcp diff --git a/cli/internal/sse/accumulator.go b/cli/internal/sse/accumulator.go index 56c106fe..8940f47d 100644 --- a/cli/internal/sse/accumulator.go +++ b/cli/internal/sse/accumulator.go @@ -19,8 +19,15 @@ import ( // Zero value is ready to use. Not safe for concurrent Append calls — the SDK // invokes its callback sequentially on a single goroutine, so this matches // the only contract that exists today. +// +// Demuxes by ResponseType so the answer string is not polluted by thinking +// or reflection fragments — the model layer (internal/models/chat/ +// remote_api.go) emits ResponseTypeThinking events whenever the upstream +// LLM produces reasoning_content (GPT-5 / Claude extended thinking), and +// without demux those would be silently concatenated into Result(). type Accumulator struct { - builder strings.Builder + answer strings.Builder + thinking strings.Builder References []*sdk.SearchResult SessionID string AssistantMessageID string @@ -33,8 +40,23 @@ func (a *Accumulator) Append(r *sdk.StreamResponse) { if r == nil || a.finished { return } - if r.Content != "" { - a.builder.WriteString(r.Content) + switch r.ResponseType { + case sdk.ResponseTypeAnswer: + if r.Content != "" { + a.answer.WriteString(r.Content) + } + case sdk.ResponseTypeThinking, sdk.ResponseTypeReflection: + if r.Content != "" { + a.thinking.WriteString(r.Content) + } + default: + // Frames without a typed ResponseType (legacy / metadata-only + // payloads) that still carry an answer fragment fall through here. + // Preserve the legacy contract by treating untyped Content as + // answer text. + if r.ResponseType == "" && r.Content != "" { + a.answer.WriteString(r.Content) + } } if r.SessionID != "" && a.SessionID == "" { a.SessionID = r.SessionID @@ -61,5 +83,10 @@ func (a *Accumulator) Append(r *sdk.StreamResponse) { // Done reports whether a terminal event was observed. func (a *Accumulator) Done() bool { return a.finished } -// Result returns the concatenated content string. -func (a *Accumulator) Result() string { return a.builder.String() } +// Result returns the accumulated answer-event content. +func (a *Accumulator) Result() string { return a.answer.String() } + +// Thinking returns the accumulated reasoning / reflection content surfaced +// by the upstream LLM (only populated when the active model produces +// reasoning_content). Empty for non-reasoning models. +func (a *Accumulator) Thinking() string { return a.thinking.String() } diff --git a/cli/internal/text/timeago.go b/cli/internal/text/timeago.go index 7666fdad..d2920f10 100644 --- a/cli/internal/text/timeago.go +++ b/cli/internal/text/timeago.go @@ -6,8 +6,7 @@ import ( "time" ) -// FuzzyAgo formats t relative to now in the gh house style. -// Mirrors github.com/cli/cli/internal/text/datetime.go FuzzyAgo. +// FuzzyAgo formats t relative to now as a short relative-time string. // // Examples: //