refactor(cli): drop --dry-run + introduce bare-JSON output path

Two intertwined mainstream-alignment moves bundled because they share
the migration target (every command's --json path):

1. Drop --dry-run entirely. Survey of comparable API-wrapper CLIs
   (gh, aws, stripe, lark): none expose --dry-run. The mainstream that
   does (kubectl/git/helm/ansible) operates on declarative manifests
   or local state where the preview is materially different from the
   executed action. WeKnora's CLI just echoed the same parameters
   that would have gone on the wire — the preview added no real
   signal over `--help` + reading the call site. Removes:
   - root --dry-run persistent flag + cmdutil/dryrun.go
   - DryRun fields + EmitDryRun calls in 12 write commands
   - format.Envelope.DryRun field
   - 8 corresponding *_test.go cases
   - --dry-run mention from README.md and CHANGELOG.md
   - "dry_run":false from 16 golden envelopes

2. Migrate every --json output to bare data:
   - New format.WriteJSON / WriteJSONFiltered helpers
     (cli/internal/format/bare.go) share filterArrayItems /
     filterObjectKeys / writeJQ with the (still-live for now) envelope
     filter helpers.
   - Read commands (kb/doc/session list+view, search chunks/docs/
     sessions/kb, auth list/status, agent list/view, context list,
     doctor) emit bare arrays / objects on stdout.
   - Write commands (kb create/edit/delete/pin/empty, doc upload/
     upload_recursive/delete, session delete, auth login/logout/
     refresh/token, link/unlink, context add/use/remove, agent
     invoke, chat, api, version) emit bare result objects. Risk
     classification dropped — the resource + exit code already
     convey the action.

Per-command shape changes:
   list / search       → []T   (was {ok, data:{items:[…]}})
   view                → T     (was {ok, data:T, _meta:…})
   create / edit       → T
   delete / pin / etc. → {id, …action result…}
   doctor              → {summary, checks}
   api                 → {status, headers, body}

_meta dropped on the read path:
   pagination (page/page_size/total/has_more) — agents iterate with
   --all-pages or accept --limit (gh CLI parity);
   kb_id / context echo — caller already knows what it asked for.

Acceptance contract goldens regenerated for the new bare shape.
Error envelope on stdout (PrintErrorEnvelope) stays live for now —
the envelope-infra deletion lands in the next commit.
This commit is contained in:
nullkey
2026-05-14 23:28:14 +08:00
committed by lyingbug
parent bdc589e1c0
commit cc8254f862
95 changed files with 579 additions and 911 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ Earlier history (v0.0 through v0.2) is recorded in the project root
in the target state (emits `_meta.warnings`, no server call).
- `kb empty` — bulk-delete documents while preserving the KB record and
its config. High-risk-write; exit-10 confirmation in non-TTY / `--json`
paths; `--dry-run` emits `risk` + `dry_run:true`.
paths.
- `doc view <id>` — show one document's metadata (title, file name,
type, size, parse status, embedding model, processed-at, error
message). Counterpart to `kb view` and `session view`.
-3
View File
@@ -139,9 +139,6 @@ The full schema, error-code registry, and exit-code protocol (0 / 1 / 2 / 10
Designed to be agent-first:
- `--dry-run` previews any write command (kb create/delete, doc
upload/delete, api POST/PUT/PATCH/DELETE) without hitting the server,
emitting an envelope with `risk` classification and `dry_run: true`.
- `-y/--yes` skips confirmation prompts for high-risk writes. **Without
`-y` on a non-TTY/`--json` invocation, destructive commands return
`error.code: input.confirmation_required` and exit code 10** so an
@@ -1 +1 @@
{"ok":false,"error":{"code":"auth.unauthenticated","message":"fetch current user: HTTP error 401: {\"error\":\"unauthenticated\"}","hint":"run `weknora auth login`"},"dry_run":false}
{"ok":false,"error":{"code":"auth.unauthenticated","message":"fetch current user: HTTP error 401: {\"error\":\"unauthenticated\"}","hint":"run `weknora auth login`"}}
+1 -1
View File
@@ -1 +1 @@
{"ok":true,"data":{"context":"","user_id":"usr_abc","email":"user@example.com","tenant_id":42,"tenant_name":"Acme"},"_meta":{"tenant_id":42},"dry_run":false}
{"context":"","user_id":"usr_abc","email":"user@example.com","tenant_id":42,"tenant_name":"Acme"}
+1 -1
View File
@@ -1 +1 @@
{"ok":true,"data":{"current_context":"production","previous_context":"staging"},"dry_run":false}
{"current_context":"production","previous_context":"staging"}
@@ -1 +1 @@
{"ok":false,"data":{"summary":{"all_passed":false,"passed":1,"failed":1,"skipped":2},"checks":[{"name":"base_url_reachable","status":"fail","details":"server returned 500","hint":"verify the host configured for the active context (run `weknora auth login --host=...`) and network reachability"},{"name":"auth_credential","status":"skip","details":"prereq failed: base_url_reachable"},{"name":"server_version","status":"skip","details":"prereq failed: auth_credential"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]},"dry_run":false}
{"summary":{"all_passed":false,"passed":1,"failed":1,"skipped":2},"checks":[{"name":"base_url_reachable","status":"fail","details":"server returned 500","hint":"verify the host configured for the active context (run `weknora auth login --host=...`) and network reachability"},{"name":"auth_credential","status":"skip","details":"prereq failed: base_url_reachable"},{"name":"server_version","status":"skip","details":"prereq failed: auth_credential"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]}
@@ -1 +1 @@
{"ok":true,"data":{"summary":{"all_passed":false,"passed":1,"failed":0,"skipped":3},"checks":[{"name":"base_url_reachable","status":"skip","details":"offline mode"},{"name":"auth_credential","status":"skip","details":"offline mode"},{"name":"server_version","status":"skip","details":"offline mode"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]},"dry_run":false}
{"summary":{"all_passed":false,"passed":1,"failed":0,"skipped":3},"checks":[{"name":"base_url_reachable","status":"skip","details":"offline mode"},{"name":"auth_credential","status":"skip","details":"offline mode"},{"name":"server_version","status":"skip","details":"offline mode"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]}
@@ -1 +1 @@
{"ok":false,"error":{"code":"auth.forbidden","message":"list knowledge bases: HTTP error 403: {\"error\":\"forbidden\"}","hint":"active context lacks permission for this resource"},"dry_run":false}
{"ok":false,"error":{"code":"auth.forbidden","message":"list knowledge bases: HTTP error 403: {\"error\":\"forbidden\"}","hint":"active context lacks permission for this resource"}}
+1 -1
View File
@@ -1 +1 @@
{"ok":true,"data":{"items":[{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},{"id":"kb2","name":"API Reference","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":12,"chunk_count":340,"is_processing":false,"processing_count":0}]},"dry_run":false}
[{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},{"id":"kb2","name":"API Reference","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":12,"chunk_count":340,"is_processing":false,"processing_count":0}]
@@ -1 +1 @@
{"ok":true,"data":{"items":[]},"dry_run":false}
[]
@@ -1 +1 @@
{"ok":false,"error":{"code":"resource.not_found","message":"get knowledge base \"missing\": HTTP error 404: {\"error\":\"not found\"}","hint":"verify the resource ID; list available with `weknora kb list`"},"dry_run":false}
{"ok":false,"error":{"code":"resource.not_found","message":"get knowledge base \"missing\": HTTP error 404: {\"error\":\"not found\"}","hint":"verify the resource ID; list available with `weknora kb list`"}}
+1 -1
View File
@@ -1 +1 @@
{"ok":true,"data":{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"Internal onboarding handbook","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},"dry_run":false}
{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"Internal onboarding handbook","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0}
@@ -1 +1 @@
{"ok":false,"error":{"code":"input.invalid_argument","message":"--no-vector and --no-keyword cannot both be set","hint":"see `weknora <command> --help` for valid usage"},"dry_run":false}
{"ok":false,"error":{"code":"input.invalid_argument","message":"--no-vector and --no-keyword cannot both be set","hint":"see `weknora <command> --help` for valid usage"}}
@@ -1 +1 @@
{"ok":false,"error":{"code":"resource.not_found","message":"hybrid search: HTTP error 404: {\"error\":\"not found\"}","hint":"verify the resource ID; list available with `weknora kb list`"},"dry_run":false}
{"ok":false,"error":{"code":"resource.not_found","message":"hybrid search: HTTP error 404: {\"error\":\"not found\"}","hint":"verify the resource ID; list available with `weknora kb list`"}}
+1 -1
View File
@@ -1 +1 @@
{"ok":true,"data":{"items":[{"id":"chunk-1","content":"first chunk content","knowledge_id":"doc-1","chunk_index":0,"knowledge_title":"Doc 1","start_at":0,"end_at":0,"seq":0,"score":0.92,"match_type":0,"chunk_type":"","image_info":"","metadata":null,"knowledge_filename":"","knowledge_source":"","knowledge_channel":""},{"id":"chunk-2","content":"second chunk content","knowledge_id":"doc-2","chunk_index":1,"knowledge_title":"Doc 2","start_at":0,"end_at":0,"seq":0,"score":0.81,"match_type":1,"chunk_type":"","image_info":"","metadata":null,"knowledge_filename":"","knowledge_source":"","knowledge_channel":""}]},"_meta":{"kb_id":"11111111-1111-4111-8111-111111111111"},"dry_run":false}
[{"id":"chunk-1","content":"first chunk content","knowledge_id":"doc-1","chunk_index":0,"knowledge_title":"Doc 1","start_at":0,"end_at":0,"seq":0,"score":0.92,"match_type":0,"chunk_type":"","image_info":"","metadata":null,"knowledge_filename":"","knowledge_source":"","knowledge_channel":""},{"id":"chunk-2","content":"second chunk content","knowledge_id":"doc-2","chunk_index":1,"knowledge_title":"Doc 2","start_at":0,"end_at":0,"seq":0,"score":0.81,"match_type":1,"chunk_type":"","image_info":"","metadata":null,"knowledge_filename":"","knowledge_source":"","knowledge_channel":""}]
+1 -1
View File
@@ -1 +1 @@
{"ok":true,"data":{"commit":"none","date":"unknown","version":"dev"},"dry_run":false}
{"commit":"none","date":"unknown","version":"dev"}
@@ -1 +1 @@
{"ok":false,"error":{"code":"auth.unauthenticated","message":"fetch current user: HTTP error 401: {\"error\":\"unauthenticated\"}","hint":"run `weknora auth login`"},"dry_run":false}
{"ok":false,"error":{"code":"auth.unauthenticated","message":"fetch current user: HTTP error 401: {\"error\":\"unauthenticated\"}","hint":"run `weknora auth login`"}}
+1 -1
View File
@@ -1 +1 @@
{"ok":true,"data":{"user_id":"usr_abc","tenant_id":42},"dry_run":false}
{"ok":true,"data":{"user_id":"usr_abc","tenant_id":42}}
+1 -5
View File
@@ -183,11 +183,7 @@ func runInvoke(ctx context.Context, opts *InvokeOptions, jopts *cmdutil.JSONOpti
AgentID: opts.AgentID,
Query: opts.Query,
}
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(data, nil),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, data, jopts.Fields, jopts.JQ)
}
out := iostreams.IO.Out
+18 -26
View File
@@ -76,30 +76,24 @@ func TestInvoke_AccumulateMode_EmitsJSONEnvelope(t *testing.T) {
if err := runInvoke(context.Background(), opts, &cmdutil.JSONOptions{}, svc); err != nil {
t.Fatalf("runInvoke: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data invokeData `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
var got invokeData
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v\n%s", err, out.String())
}
if !env.OK {
t.Fatalf("ok=false: %s", out.String())
if got.Answer != "Hello world." {
t.Errorf("answer = %q, want %q", got.Answer, "Hello world.")
}
if env.Data.Answer != "Hello world." {
t.Errorf("answer = %q, want %q", env.Data.Answer, "Hello world.")
if got.AgentID != "ag_x" {
t.Errorf("agent_id = %q, want ag_x", got.AgentID)
}
if env.Data.AgentID != "ag_x" {
t.Errorf("agent_id = %q, want ag_x", env.Data.AgentID)
if got.Query != "ping" {
t.Errorf("query = %q, want ping", got.Query)
}
if env.Data.Query != "ping" {
t.Errorf("query = %q, want ping", env.Data.Query)
if got.SessionID != "sess_auto" {
t.Errorf("session_id = %q, want sess_auto", got.SessionID)
}
if env.Data.SessionID != "sess_auto" {
t.Errorf("session_id = %q, want sess_auto", env.Data.SessionID)
}
if len(env.Data.References) != 1 || env.Data.References[0].KnowledgeID != "k1" {
t.Errorf("references missing: %+v", env.Data.References)
if len(got.References) != 1 || got.References[0].KnowledgeID != "k1" {
t.Errorf("references missing: %+v", got.References)
}
}
@@ -161,17 +155,15 @@ func TestInvoke_ToolEventsCaptured(t *testing.T) {
if err := runInvoke(context.Background(), opts, &cmdutil.JSONOptions{}, svc); err != nil {
t.Fatalf("runInvoke: %v", err)
}
var env struct {
Data invokeData `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
var got invokeData
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if len(env.Data.ToolEvents) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(env.Data.ToolEvents))
if len(got.ToolEvents) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(got.ToolEvents))
}
if env.Data.ToolEvents[0].ID != "call_1" {
t.Errorf("tool_calls[0].id = %q, want call_1", env.Data.ToolEvents[0].ID)
if got.ToolEvents[0].ID != "call_1" {
t.Errorf("tool_calls[0].id = %q, want call_1", got.ToolEvents[0].ID)
}
}
+2 -11
View File
@@ -39,11 +39,6 @@ type ListOptions struct {
Limit int
}
// listResult is the typed payload emitted under data.items.
type listResult struct {
Items []sdk.Agent `json:"items"`
}
// NewCmdList builds `weknora agent list`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
@@ -65,7 +60,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (0 = no cap, 1..10000 = explicit)")
cmdutil.AddJSONFlags(cmd, agentListFields)
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.")
aiclient.SetAgentHelp(cmd, "Lists tenant-visible agents (built-in + custom) as a bare JSON array of Agent objects (empty `[]` when none). --limit caps the returned slice. Use `--json id,name` to project fields, `--jq` for arbitrary reshape.")
return cmd
}
@@ -97,11 +92,7 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(listResult{Items: items}, nil),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, items, jopts.Fields, jopts.JQ)
}
if len(items) == 0 {
+12 -20
View File
@@ -38,8 +38,8 @@ func TestList_Empty_JSON(t *testing.T) {
if err := runList(context.Background(), &ListOptions{}, &cmdutil.JSONOptions{}, &fakeListSvc{}); err != nil {
t.Fatalf("runList: %v", err)
}
if !strings.Contains(out.String(), `"items":[]`) {
t.Errorf("expected items:[], got %q", out.String())
if got := strings.TrimSpace(out.String()); got != "[]" {
t.Errorf("expected bare `[]`, got %q", got)
}
}
@@ -72,21 +72,17 @@ func TestList_NonEmpty_JSON_SortsByUpdatedAtDesc(t *testing.T) {
if err := runList(context.Background(), &ListOptions{}, &cmdutil.JSONOptions{}, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
var env struct {
Data struct {
Items []sdk.Agent `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
var got []sdk.Agent
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if len(env.Data.Items) != 3 {
t.Fatalf("len = %d, want 3", len(env.Data.Items))
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
wantOrder := []string{"ag_new", "ag_mid", "ag_old"}
for i, w := range wantOrder {
if env.Data.Items[i].ID != w {
t.Errorf("position %d: got %s, want %s (updated_at desc)", i, env.Data.Items[i].ID, w)
if got[i].ID != w {
t.Errorf("position %d: got %s, want %s (updated_at desc)", i, got[i].ID, w)
}
}
}
@@ -100,16 +96,12 @@ func TestList_JSON_FieldFilter(t *testing.T) {
if err := runList(context.Background(), &ListOptions{}, jopts, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
var env struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
var got []map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if _, has := env.Data.Items[0]["description"]; has {
t.Errorf("description should be filtered out: %+v", env.Data.Items[0])
if _, has := got[0]["description"]; has {
t.Errorf("description should be filtered out: %+v", got[0])
}
}
+8 -10
View File
@@ -15,9 +15,9 @@ import (
)
// agentViewFields enumerates fields surfaced for `--json` discovery on
// `agent view`. Single-resource shape: filter applies to data itself.
// Config sub-fields are intentionally omitted — too granular for naked
// projection; use `--jq '.data.config'` to reach them.
// `agent view`. Filter applies to the bare Agent object. Config sub-fields
// are intentionally omitted — too granular for naked projection; use
// `--jq '.config'` to reach them.
var agentViewFields = []string{
"id", "name", "description", "avatar",
"is_builtin", "tenant_id", "created_by",
@@ -36,10 +36,10 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
Short: "Show a custom agent's configuration",
Long: `Renders the agent's metadata (id / name / description / created-by /
timestamps) plus a compact config summary (mode, model, allowed tools, KB
scope). Pass --json for the full envelope including the nested config
struct — or --jq '.data.config' to extract just the config.`,
scope). Pass --json for the full Agent object including the nested config
struct — or --jq '.config' to extract just the config.`,
Example: ` weknora agent view ag_abc
weknora agent view ag_abc --json | jq '.data.config.allowed_tools'`,
weknora agent view ag_abc --json | jq '.config.allowed_tools'`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
@@ -54,7 +54,7 @@ struct — or --jq '.data.config' to extract just the config.`,
},
}
cmdutil.AddJSONFlags(cmd, agentViewFields)
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.")
aiclient.SetAgentHelp(cmd, "Fetches an agent by ID. Returns the full sdk.Agent (with nested config) as a bare JSON object. Errors: resource.not_found when the agent ID does not exist or is not visible to the active tenant.")
return cmd
}
@@ -64,9 +64,7 @@ func runView(ctx context.Context, jopts *cmdutil.JSONOptions, svc ViewService, a
return cmdutil.WrapHTTP(err, "fetch agent %s", agentID)
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.Success(a, nil),
jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, a, jopts.Fields, jopts.JQ)
}
renderAgent(iostreams.IO.Out, a)
return nil
+8 -8
View File
@@ -74,21 +74,21 @@ func TestView_Human_OmitsEmptyFields(t *testing.T) {
}
}
func TestView_JSON_Envelope(t *testing.T) {
func TestView_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Agent{ID: "ag_json", Name: "JSONy"}}
if err := runView(context.Background(), &cmdutil.JSONOptions{}, svc, "ag_json"); err != nil {
t.Fatalf("runView: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data sdk.Agent `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
var got sdk.Agent
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if !env.OK || env.Data.ID != "ag_json" {
t.Errorf("envelope shape wrong: ok=%v id=%s", env.OK, env.Data.ID)
if got.ID != "ag_json" || got.Name != "JSONy" {
t.Errorf("bare object shape wrong: id=%s name=%s", got.ID, got.Name)
}
if strings.Contains(out.String(), `"ok":`) || strings.Contains(out.String(), `"data":`) {
t.Errorf("bare output must not carry envelope keys, got %q", out.String())
}
}
+3 -25
View File
@@ -34,7 +34,6 @@ type Options struct {
Method string
Data string
Input string // --input: file path, "-" for stdin
DryRun bool
Yes bool
StdinReader io.Reader // overridden by tests; defaults to iostreams.IO.In
}
@@ -68,7 +67,6 @@ Examples:
weknora api /api/v1/knowledge-bases/kb_xxx -X DELETE`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.DryRun = cmdutil.IsDryRun(c)
opts.Yes, _ = c.Flags().GetBool("yes")
jopts, err := cmdutil.CheckJSONFlags(c)
if err != nil {
@@ -77,15 +75,11 @@ Examples:
method := resolveMethod(opts)
// Escape-hatch DELETE through `weknora api` is just as destructive
// as `weknora kb delete` — exit-10 protocol must apply (AGENTS.md).
// Dry-run is read-only preview, so it skips confirmation.
if !opts.DryRun && method == http.MethodDelete {
if method == http.MethodDelete {
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, jopts.Enabled(), "endpoint", args[0]); err != nil {
return err
}
}
if opts.DryRun {
return runAPI(c.Context(), opts, jopts, nil, method, args[0])
}
cli, err := f.Client()
if err != nil {
return err
@@ -166,21 +160,6 @@ func runAPI(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
body = json.RawMessage(contents)
}
// --dry-run only meaningful for write methods; GET/HEAD have no side
// effect to preview, so we proceed normally even with --dry-run.
if opts.DryRun && method != http.MethodGet && method != http.MethodHead {
level := format.RiskWrite
if method == http.MethodDelete {
level = format.RiskHighRiskWrite
}
preview := map[string]any{"method": method, "path": path}
if body != nil {
preview["body"] = body
}
return cmdutil.EmitDryRun(jopts.Enabled(), preview, nil,
&format.Risk{Level: level, Action: fmt.Sprintf("%s %s", method, path)})
}
resp, err := svc.Raw(ctx, method, path, body)
if err != nil {
// Transport / DNS failure (Raw never returns a typed HTTP error of its
@@ -216,12 +195,11 @@ func runAPI(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
hdrs[k] = v[0]
}
}
env := format.Success(map[string]any{
return format.WriteJSON(out, map[string]any{
"status": resp.StatusCode,
"headers": hdrs,
"body": bodyAny,
}, nil)
return format.WriteEnvelope(out, env)
})
}
if _, err := out.Write(respBody); err != nil {
+12 -19
View File
@@ -65,28 +65,22 @@ func TestAPI_GetSuccess_JSON(t *testing.T) {
if err := runAPI(context.Background(), &Options{}, &cmdutil.JSONOptions{}, cli, "GET", "/api/v1/foo"); err != nil {
t.Fatalf("runAPI: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body map[string]any `json:"body"`
} `json:"data"`
var got struct {
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body map[string]any `json:"body"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v\n%s", err, out.String())
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("decode bare JSON: %v\n%s", err, out.String())
}
if !env.OK {
t.Errorf("expected ok:true, got %s", out.String())
if got.Status != 200 {
t.Errorf("status: want 200, got %d", got.Status)
}
if env.Data.Status != 200 {
t.Errorf("status: want 200, got %d", env.Data.Status)
if got.Headers["Content-Type"] != "application/json" {
t.Errorf("Content-Type header missing: %v", got.Headers)
}
if env.Data.Headers["Content-Type"] != "application/json" {
t.Errorf("Content-Type header missing: %v", env.Data.Headers)
}
if got, ok := env.Data.Body["value"]; !ok || got.(float64) != 42 {
t.Errorf("body.value: want 42, got %v", env.Data.Body)
if v, ok := got.Body["value"]; !ok || v.(float64) != 42 {
t.Errorf("body.value: want 42, got %v", got.Body)
}
}
@@ -211,7 +205,6 @@ func TestAPI_PathWithoutSlash(t *testing.T) {
func withRootHarness(api *cobra.Command, args ...string) *cobra.Command {
root := &cobra.Command{Use: "weknora"}
root.PersistentFlags().BoolP("yes", "y", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.AddCommand(api)
root.SetArgs(append([]string{"api"}, args...))
root.SetContext(context.Background())
+1 -3
View File
@@ -68,9 +68,7 @@ func runList(jopts *cmdutil.JSONOptions, f *cmdutil.Factory) error {
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.Success(entries, &format.Meta{Context: cfg.CurrentContext}),
jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, entries, jopts.Fields, jopts.JQ)
}
if len(entries) == 0 {
fmt.Fprintln(iostreams.IO.Out, "No contexts configured. Run `weknora auth login` to create one.")
+10 -18
View File
@@ -48,7 +48,7 @@ func TestList_Empty(t *testing.T) {
assert.Contains(t, out.String(), "No contexts configured")
}
func TestList_JSONEnvelope(t *testing.T) {
func TestList_JSON_BareArray(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "prod",
@@ -59,24 +59,16 @@ func TestList_JSONEnvelope(t *testing.T) {
}
require.NoError(t, runList(&cmdutil.JSONOptions{}, newListFactory(cfg)))
var env struct {
OK bool `json:"ok"`
Data []listEntry `json:"data"`
Meta struct {
Context string `json:"context"`
} `json:"_meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.True(t, env.OK)
assert.Equal(t, "prod", env.Meta.Context)
require.Len(t, env.Data, 2)
var got []listEntry
require.NoError(t, json.Unmarshal(out.Bytes(), &got))
require.Len(t, got, 2)
// Sorted: prod < staging.
assert.Equal(t, "prod", env.Data[0].Name)
assert.True(t, env.Data[0].Current)
assert.Equal(t, ModeBearer, env.Data[0].Mode)
assert.Equal(t, "staging", env.Data[1].Name)
assert.False(t, env.Data[1].Current)
assert.Equal(t, ModeAPIKey, env.Data[1].Mode)
assert.Equal(t, "prod", got[0].Name)
assert.True(t, got[0].Current)
assert.Equal(t, ModeBearer, got[0].Mode)
assert.Equal(t, "staging", got[1].Name)
assert.False(t, got[1].Current)
assert.Equal(t, ModeAPIKey, got[1].Mode)
}
func TestModeFromRefs(t *testing.T) {
+1 -6
View File
@@ -211,12 +211,7 @@ func saveContextRef(opts *LoginOptions, jopts *cmdutil.JSONOptions, f *cmdutil.F
result.User = user.Email
result.TenantID = user.TenantID
}
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.Success(result, &format.Meta{
Context: opts.Context,
TenantID: ctx.TenantID,
}),
jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, result, jopts.Fields, jopts.JQ)
}
who := opts.Context
if user != nil {
+1 -3
View File
@@ -97,9 +97,7 @@ func runLogout(opts *LogoutOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Facto
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.Success(logoutResult{Removed: targets}, nil),
jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, logoutResult{Removed: targets}, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Logged out of %d context(s): %s\n", len(targets), strings.Join(targets, ", "))
return nil
+1 -3
View File
@@ -113,9 +113,7 @@ func runRefresh(ctx context.Context, opts *RefreshOptions, jopts *cmdutil.JSONOp
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.Success(refreshResult{Context: name}, &format.Meta{Context: name}),
jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, refreshResult{Context: name}, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Refreshed access token for context %s\n", name)
return nil
+6 -10
View File
@@ -2,7 +2,6 @@ package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -12,7 +11,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
@@ -207,14 +205,12 @@ func TestRefresh_JSONOutput(t *testing.T) {
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: true, AccessToken: "a", RefreshToken: "r"}}
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.JSONOptions{}, f, stubSvc(svc)))
var env format.Envelope
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.True(t, env.OK)
// payload should not leak the actual token values.
body := out.String()
assert.NotContains(t, body, "ok-refresh", "envelope must not leak refresh token")
assert.NotContains(t, body, "\"a\"", "envelope must not leak the new access token")
assert.NotContains(t, body, "\"r\"", "envelope must not leak the new refresh token")
// payload must not leak the actual token values.
assert.NotContains(t, body, "ok-refresh", "output must not leak refresh token")
assert.NotContains(t, body, "\"a\"", "output must not leak the new access token")
assert.NotContains(t, body, "\"r\"", "output must not leak the new refresh token")
// must mention the context name so agents can confirm what was refreshed
assert.True(t, strings.Contains(body, "prod"), "envelope should reference the refreshed context")
assert.True(t, strings.Contains(body, "prod"), "output should reference the refreshed context")
assert.NotContains(t, body, `"ok":`)
}
+1 -8
View File
@@ -73,23 +73,16 @@ func runStatus(ctx context.Context, jopts *cmdutil.JSONOptions, f *cmdutil.Facto
}
if jopts.Enabled() {
var tenantID uint64
result := statusResult{Context: cfg.CurrentContext}
if user != nil {
result.UserID = user.ID
result.Email = user.Email
result.TenantID = user.TenantID
tenantID = user.TenantID
}
if tenant != nil {
result.TenantName = tenant.Name
}
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.Success(result, &format.Meta{
Context: cfg.CurrentContext,
TenantID: tenantID,
}),
jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, result, jopts.Fields, jopts.JQ)
}
host := ""
+4 -2
View File
@@ -70,8 +70,10 @@ func TestRunStatus_JSONOutput(t *testing.T) {
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
svc := &fakeStatusService{resp: newCurrentUserResponse(&sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7}, nil)}
require.NoError(t, runStatus(context.Background(), &cmdutil.JSONOptions{}, f, svc))
assert.True(t, strings.HasPrefix(out.String(), `{"ok":true`), "got: %q", out.String())
assert.Contains(t, out.String(), `"email":"a@b.c"`)
got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"context":"prod"`), "expected bare object, got: %q", got)
assert.NotContains(t, got, `"ok":`)
assert.Contains(t, got, `"email":"a@b.c"`)
}
func TestRunStatus_NoSDKClient(t *testing.T) {
+3 -4
View File
@@ -12,7 +12,7 @@ import (
)
// authTokenFields lists fields available for `auth token --json=` projection.
// Single-resource envelope shape (data is the token result, not data.items).
// Single-resource shape: filter applies to the bare token object directly.
var authTokenFields = []string{"token", "mode", "context"}
type tokenResult struct {
@@ -117,9 +117,8 @@ func runToken(f *cmdutil.Factory, jopts *cmdutil.JSONOptions) error {
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.Success(tokenResult{Token: token, Mode: mode, Context: ctxName},
&format.Meta{Context: ctxName}),
return format.WriteJSONFiltered(iostreams.IO.Out,
tokenResult{Token: token, Mode: mode, Context: ctxName},
jopts.Fields, jopts.JQ)
}
+13 -21
View File
@@ -80,22 +80,16 @@ func TestAuthToken_JSON(t *testing.T) {
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.JSONOptions{}); err != nil {
t.Fatalf("runToken: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Token string `json:"token"`
Mode string `json:"mode"`
Context string `json:"context"`
} `json:"data"`
var got struct {
Token string `json:"token"`
Mode string `json:"mode"`
Context string `json:"context"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v\n%s", err, out.String())
}
if !env.OK {
t.Errorf("ok=false")
}
if env.Data.Token != "jwt-xyz" || env.Data.Mode != "bearer" || env.Data.Context != "prod" {
t.Errorf("envelope payload wrong: %+v", env.Data)
if got.Token != "jwt-xyz" || got.Mode != "bearer" || got.Context != "prod" {
t.Errorf("payload wrong: %+v", got)
}
}
@@ -114,17 +108,15 @@ func TestAuthToken_JSON_FieldFilter(t *testing.T) {
if err := runToken(tokenTestFactory(t, cfg, store), jopts); err != nil {
t.Fatalf("runToken: %v", err)
}
var env struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if _, has := env.Data["mode"]; has {
t.Errorf("mode should be filtered out: %+v", env.Data)
if _, has := got["mode"]; has {
t.Errorf("mode should be filtered out: %+v", got)
}
if env.Data["token"] != "sk_42" {
t.Errorf("token wrong: %+v", env.Data)
if got["token"] != "sk_42" {
t.Errorf("token wrong: %+v", got)
}
}
+1 -5
View File
@@ -241,11 +241,7 @@ func runChat(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
KBID: opts.KBID,
Query: opts.Query,
}
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(data, &format.Meta{KBID: opts.KBID}),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, data, jopts.Fields, jopts.JQ)
}
// Human / non-JSON paths: streaming mode already wrote the answer body
+23 -29
View File
@@ -122,42 +122,36 @@ func TestChat_JSONMode(t *testing.T) {
t.Errorf("expected empty stderr in JSON mode, got %q", errBuf.String())
}
var env struct {
OK bool `json:"ok"`
Data struct {
Answer string `json:"answer"`
SessionID string `json:"session_id"`
AssistantMessageID string `json:"assistant_message_id"`
KBID string `json:"kb_id"`
Query string `json:"query"`
References []struct {
KnowledgeID string `json:"knowledge_id"`
} `json:"references"`
} `json:"data"`
var got struct {
Answer string `json:"answer"`
SessionID string `json:"session_id"`
AssistantMessageID string `json:"assistant_message_id"`
KBID string `json:"kb_id"`
Query string `json:"query"`
References []struct {
KnowledgeID string `json:"knowledge_id"`
} `json:"references"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v\n%s", err, out.String())
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, out.String())
}
if !env.OK {
t.Errorf("expected ok:true, got %s", out.String())
if got.Answer != "answer body" {
t.Errorf("answer: got %q", got.Answer)
}
if env.Data.Answer != "answer body" {
t.Errorf("answer: got %q", env.Data.Answer)
if got.SessionID != "sess_auto" {
t.Errorf("session_id: got %q", got.SessionID)
}
if env.Data.SessionID != "sess_auto" {
t.Errorf("session_id: got %q", env.Data.SessionID)
if got.AssistantMessageID != "msg_99" {
t.Errorf("assistant_message_id: got %q", got.AssistantMessageID)
}
if env.Data.AssistantMessageID != "msg_99" {
t.Errorf("assistant_message_id: got %q", env.Data.AssistantMessageID)
if got.KBID != "kb_42" {
t.Errorf("kb_id: got %q", got.KBID)
}
if env.Data.KBID != "kb_42" {
t.Errorf("kb_id: got %q", env.Data.KBID)
if got.Query != "q" {
t.Errorf("query: got %q", got.Query)
}
if env.Data.Query != "q" {
t.Errorf("query: got %q", env.Data.Query)
}
if len(env.Data.References) != 1 || env.Data.References[0].KnowledgeID != "k1" {
t.Errorf("references payload missing: %+v", env.Data.References)
if len(got.References) != 1 || got.References[0].KnowledgeID != "k1" {
t.Errorf("references payload missing: %+v", got.References)
}
}
+2 -7
View File
@@ -101,14 +101,9 @@ func runAdd(opts *AddOptions, jopts *cmdutil.JSONOptions, name string) error {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
}
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("add context %s", name)}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.SuccessWithRisk(
addResult{Name: name, Host: host, User: opts.User, Current: wasFirst},
&format.Meta{Context: cfg.CurrentContext},
risk,
),
return format.WriteJSONFiltered(iostreams.IO.Out,
addResult{Name: name, Host: host, User: opts.User, Current: wasFirst},
jopts.Fields, jopts.JQ)
}
if wasFirst {
+10 -8
View File
@@ -7,7 +7,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
@@ -127,15 +126,18 @@ func TestAdd_JSON(t *testing.T) {
if err := runAdd(&AddOptions{Host: "https://my.example.com"}, &cmdutil.JSONOptions{}, "staging"); err != nil {
t.Fatalf("runAdd: %v", err)
}
var env format.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("invalid JSON envelope: %v\noutput=%q", err, out.String())
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\noutput=%q", err, out.String())
}
if !env.OK {
t.Fatalf("envelope.ok=false, error=%+v", env.Error)
if got["name"] != "staging" {
t.Errorf("name should be staging, got %v", got)
}
if env.Risk == nil || env.Risk.Level != format.RiskWrite {
t.Errorf("envelope.risk should be write-level, got %+v", env.Risk)
if got["host"] != "https://my.example.com" {
t.Errorf("host wrong: %v", got)
}
if got["current"] != true {
t.Errorf("first added context must be current=true, got %v", got)
}
}
+1 -3
View File
@@ -73,9 +73,7 @@ func runList(jopts *cmdutil.JSONOptions) error {
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.Success(entries, &format.Meta{Context: cfg.CurrentContext}),
jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, entries, jopts.Fields, jopts.JQ)
}
if len(entries) == 0 {
fmt.Fprintln(iostreams.IO.Out, "No contexts configured. Run `weknora auth login` (or `weknora context add`) to create one.")
+7 -20
View File
@@ -7,7 +7,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
@@ -80,31 +79,19 @@ func TestList_JSON(t *testing.T) {
t.Fatalf("runList: %v", err)
}
var env format.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("invalid JSON envelope: %v\noutput=%q", err, out.String())
}
if !env.OK {
t.Fatalf("envelope.ok=false, error=%+v", env.Error)
}
if env.Meta == nil || env.Meta.Context != "staging" {
t.Errorf("envelope._meta.context should be %q, got %+v", "staging", env.Meta)
}
rows, ok := env.Data.([]any)
if !ok {
t.Fatalf("envelope.data should be []listEntry, got %T", env.Data)
var rows []map[string]any
if err := json.Unmarshal(out.Bytes(), &rows); err != nil {
t.Fatalf("invalid JSON: %v\noutput=%q", err, out.String())
}
if len(rows) != 2 {
t.Fatalf("expected 2 entries, got %d", len(rows))
}
// alphabetical: production before staging
first := rows[0].(map[string]any)
if first["name"] != "production" {
t.Errorf("first row should be production, got %v", first)
if rows[0]["name"] != "production" {
t.Errorf("first row should be production, got %v", rows[0])
}
second := rows[1].(map[string]any)
if second["name"] != "staging" || second["current"] != true {
t.Errorf("second row should be staging with current=true, got %v", second)
if rows[1]["name"] != "staging" || rows[1]["current"] != true {
t.Errorf("second row should be staging with current=true, got %v", rows[1])
}
}
+3 -12
View File
@@ -15,8 +15,7 @@ import (
)
type RemoveOptions struct {
Yes bool // sourced from the global -y/--yes persistent flag (matches `kb delete`)
DryRun bool
Yes bool // sourced from the global -y/--yes persistent flag (matches `kb delete`)
}
// contextRemoveFields enumerates the fields surfaced for `--json` discovery on
@@ -59,7 +58,6 @@ in scripted / --json invocations (exit code 10; see cli/AGENTS.md).`,
return err
}
opts.Yes, _ = c.Flags().GetBool("yes")
opts.DryRun = cmdutil.IsDryRun(c)
store, err := f.Secrets()
if err != nil {
return err
@@ -85,12 +83,6 @@ func runRemove(opts *RemoveOptions, jopts *cmdutil.JSONOptions, name string, sto
risk := riskForRemove(name, wasCurrent)
jsonOut := jopts.Enabled()
if opts.DryRun {
return cmdutil.EmitDryRun(jsonOut,
removeResult{Name: name, Removed: false, WasCurrent: wasCurrent},
&format.Meta{Context: cfg.CurrentContext},
risk)
}
// Confirmation only fires for removing the current context — non-current
// remove uses the same low-friction policy as `auth logout`.
if wasCurrent {
@@ -110,11 +102,10 @@ func runRemove(opts *RemoveOptions, jopts *cmdutil.JSONOptions, name string, sto
}
clearContextSecrets(store, ctx, name)
_ = risk // risk classification dropped in v0.4; exit code already signals
result := removeResult{Name: name, Removed: true, WasCurrent: wasCurrent}
if jsonOut {
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.SuccessWithRisk(result, &format.Meta{Context: cfg.CurrentContext}, risk),
jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, result, jopts.Fields, jopts.JQ)
}
if wasCurrent {
fmt.Fprintf(iostreams.IO.Out, "✓ Removed context %s (current context cleared — run `weknora context use <name>` to pick another)\n", name)
-36
View File
@@ -1,14 +1,12 @@
package contextcmd
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
"github.com/Tencent/WeKnora/cli/internal/testutil"
@@ -206,37 +204,3 @@ func TestRemove_Current_TTY_PromptNo(t *testing.T) {
}
}
func TestRemove_DryRun(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "production",
Contexts: map[string]config.Context{"production": {Host: "https://prod", TokenRef: "mem://production/access"}},
}
if err := config.Save(cfg); err != nil {
t.Fatalf("Save: %v", err)
}
store := seedStore(t, "production", "access")
if err := runRemove(&RemoveOptions{DryRun: true}, &cmdutil.JSONOptions{}, "production", store, &testutil.ConfirmPrompter{}); err != nil {
t.Fatalf("runRemove dry-run: %v", err)
}
var env format.Envelope
if jerr := json.Unmarshal(out.Bytes(), &env); jerr != nil {
t.Fatalf("invalid envelope: %v\noutput=%q", jerr, out.String())
}
if !env.OK || !env.DryRun {
t.Errorf("envelope should be ok=true, dry_run=true, got %+v", env)
}
if env.Risk == nil || env.Risk.Level != format.RiskHighRiskWrite {
t.Errorf("dry-run on current context should report high-risk-write, got %+v", env.Risk)
}
// Nothing actually mutated.
if got, _ := config.Load(); got.CurrentContext != "production" {
t.Errorf("dry-run must not mutate config")
}
if v, err := store.Get("production", "access"); err != nil || v == "" {
t.Errorf("dry-run must not touch keyring; get=%q err=%v", v, err)
}
}
+2 -2
View File
@@ -57,10 +57,10 @@ func runUse(name string) error {
if err := config.Save(cfg); err != nil {
return err
}
return format.WriteEnvelope(iostreams.IO.Out, format.Success(useResult{
return format.WriteJSON(iostreams.IO.Out, useResult{
CurrentContext: name,
PreviousContext: prev,
}, nil))
})
}
func notFoundError(name string, cfg *config.Config) error {
+2 -14
View File
@@ -18,8 +18,7 @@ import (
var docDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {
Yes bool // sourced from the global -y/--yes persistent flag (see cli/cmd/root.go)
DryRun bool
Yes bool // sourced from the global -y/--yes persistent flag (see cli/cmd/root.go)
}
// DeleteService is the narrow SDK surface this command depends on.
@@ -58,10 +57,6 @@ without the user's explicit go-ahead.`,
return err
}
opts.Yes, _ = c.Flags().GetBool("yes")
opts.DryRun = cmdutil.IsDryRun(c)
if opts.DryRun {
return runDelete(c.Context(), opts, jopts, nil, f.Prompter(), args[0])
}
cli, err := f.Client()
if err != nil {
return err
@@ -75,12 +70,6 @@ without the user's explicit go-ahead.`,
}
func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOptions, svc DeleteService, p prompt.Prompter, id string) error {
if opts.DryRun {
return cmdutil.EmitDryRun(jopts.Enabled(),
deleteResult{ID: id, Deleted: false}, nil,
&format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("delete document %s", id)})
}
if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "document", id); err != nil {
return err
}
@@ -90,8 +79,7 @@ func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOpti
}
if jopts.Enabled() {
risk := &format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("deleted document %s", id)}
return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.SuccessWithRisk(deleteResult{ID: id, Deleted: true}, nil, risk), jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted document %s\n", id)
return nil
+2 -2
View File
@@ -64,9 +64,9 @@ func TestDelete_Success_JSON(t *testing.T) {
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, scriptedConfirm{confirmReturn: true}, "doc_abc"))
got := out.String()
assert.True(t, strings.HasPrefix(got, `{"ok":true`), "envelope should start with ok:true; got %q", got)
assert.Contains(t, got, `"id":"doc_abc"`)
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"doc_abc"`), "expected bare object; got %q", got)
assert.Contains(t, got, `"deleted":true`)
assert.NotContains(t, got, `"ok":`)
}
func TestDelete_NotFound_404(t *testing.T) {
+4 -24
View File
@@ -20,8 +20,7 @@ import (
)
// docListFields enumerates the fields surfaced for `--json` discovery on
// `doc list`. These are the per-item (Knowledge) fields, not the envelope
// wrappers (items/page/total/kb_id) — filtering applies to data.items[*].
// `doc list`. Filter applies to each Knowledge object in the bare array.
var docListFields = []string{
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
"source", "channel", "parse_status", "summary_status", "enable_status",
@@ -54,14 +53,6 @@ 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. 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"`
}
// NewCmdList builds `weknora doc list`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
@@ -104,7 +95,7 @@ backend storage order is not guaranteed and varies between deployments.`,
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)
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.")
aiclient.SetAgentHelp(cmd, "Lists docs in the resolved KB as a bare JSON array of Knowledge objects (empty `[]` when none). --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
}
@@ -178,21 +169,10 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
if opts.Limit > 0 && len(items) > opts.Limit {
items = items[:opts.Limit]
}
_ = total // pagination metadata is no longer surfaced; --all-pages drains for callers who need everything
r := listResult{Items: items}
if jopts.Enabled() {
// --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)
return format.WriteJSONFiltered(iostreams.IO.Out, items, jopts.Fields, jopts.JQ)
}
if len(items) == 0 {
+5 -9
View File
@@ -79,13 +79,10 @@ func TestList_Success_JSON(t *testing.T) {
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx"))
got := out.String()
assert.True(t, strings.HasPrefix(got, `{"ok":true`), "envelope should start with ok:true; got %q", got)
assert.Contains(t, got, `"items":[`)
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `[`), "expected bare JSON array, got %q", got)
assert.Contains(t, got, `"id":"doc1"`)
assert.Contains(t, got, `"page":1`)
assert.Contains(t, got, `"page_size":20`)
assert.Contains(t, got, `"total":1`)
assert.Contains(t, got, `"kb_id":"kb_xxx"`)
assert.NotContains(t, got, `"ok":`)
assert.NotContains(t, got, `"_meta":`)
}
func TestList_Empty_Human(t *testing.T) {
@@ -102,9 +99,8 @@ func TestList_Empty_JSON(t *testing.T) {
opts := &ListOptions{PageSize: 20}
require.NoError(t, runList(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx"))
got := out.String()
assert.Contains(t, got, `"items":[]`, "items must serialize as [] not null")
assert.NotContains(t, got, `"items":null`)
got := strings.TrimSpace(out.String())
assert.Equal(t, "[]", got, "empty list must serialize as bare `[]` not null")
}
func TestList_HTTPError_500(t *testing.T) {
+12 -39
View File
@@ -36,7 +36,6 @@ type UploadOptions struct {
Recursive bool // --recursive: positional arg is a directory; walk + upload each match
Glob string // --glob: filename pattern under --recursive (default "*")
FromURL string // --from-url: ingest a remote URL via SDK CreateKnowledgeFromURL
DryRun bool
}
// UploadService is the narrow SDK surface this command depends on.
@@ -85,7 +84,6 @@ Use --recursive --glob to upload a directory tree (see Examples).`,
if err != nil {
return err
}
opts.DryRun = cmdutil.IsDryRun(c)
if err := validateUploadFlags(opts, args); err != nil {
return err
}
@@ -93,17 +91,9 @@ Use --recursive --glob to upload a directory tree (see Examples).`,
if err != nil {
return err
}
// Resolve the SDK client once; dry-run paths take nil and never
// dereference it. Hoisting avoids three near-identical
// `cli, err := f.Client()` blocks across the dispatch branches.
var cli UploadService
if !opts.DryRun {
sdkCli, err := f.Client()
if err != nil {
return err
}
cli = sdkCli
cli, err := f.Client()
if err != nil {
return err
}
switch {
@@ -157,13 +147,6 @@ func validateUploadFlags(opts *UploadOptions, args []string) error {
// `--name` becomes the FileName hint so the server's "known file extension"
// detection upgrades crawl-mode to file-download-mode when appropriate.
func runUploadFromURL(ctx context.Context, opts *UploadOptions, jopts *cmdutil.JSONOptions, svc UploadService, kbID string) error {
if opts.DryRun {
return cmdutil.EmitDryRun(jopts.Enabled(),
map[string]string{"from_url": opts.FromURL, "kb_id": kbID, "name": opts.Name},
&format.Meta{KBID: kbID},
&format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("ingest URL %s into kb %s", opts.FromURL, kbID)})
}
req := sdk.CreateKnowledgeFromURLRequest{
URL: opts.FromURL,
FileName: opts.Name,
@@ -182,20 +165,17 @@ func runUploadFromURL(ctx context.Context, opts *UploadOptions, jopts *cmdutil.J
return cmdutil.WrapHTTP(err, "ingest URL %s", opts.FromURL)
}
return printUploadSuccess(k, jopts, kbID, "ingested", "Ingested", opts.FromURL, opts.Name, opts.FromURL)
return printUploadSuccess(k, jopts, "Ingested", opts.Name, opts.FromURL)
}
// printUploadSuccess emits the post-upload result envelope (--json path)
// or the human checkmark line. Shared by single-file upload and URL ingest;
// the verb (upload/ingest), risk-action verb (uploaded/ingested), and the
// fallback display source (local path vs source URL) are the only varying
// pieces.
func printUploadSuccess(k *sdk.Knowledge, jopts *cmdutil.JSONOptions, kbID, riskVerb, humanVerb, source, customName, fallbackDisplay string) error {
// printUploadSuccess emits the post-upload result. JSON path is the bare
// Knowledge object; human path prints a checkmark line. Shared by single-
// file upload and URL ingest; humanVerb varies (uploaded/ingested) and
// fallbackDisplay covers the case when the server-recorded file_name is
// blank (URL ingest pre-redirect).
func printUploadSuccess(k *sdk.Knowledge, jopts *cmdutil.JSONOptions, humanVerb, customName, fallbackDisplay string) error {
if jopts.Enabled() {
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("%s %s", riskVerb, source)}
return format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.SuccessWithRisk(k, &format.Meta{KBID: kbID}, risk),
jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, k, jopts.Fields, jopts.JQ)
}
displayed := customName
if displayed == "" {
@@ -230,16 +210,9 @@ func validateUploadPath(path string) error {
}
func runUpload(ctx context.Context, opts *UploadOptions, jopts *cmdutil.JSONOptions, svc UploadService, kbID, path string) error {
if opts.DryRun {
return cmdutil.EmitDryRun(jopts.Enabled(),
map[string]string{"file": path, "kb_id": kbID, "name": opts.Name},
&format.Meta{KBID: kbID},
&format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("upload %s to kb %s", path, kbID)})
}
k, err := svc.CreateKnowledgeFromFile(ctx, kbID, path, nil /*metadata*/, nil /*enableMultimodel*/, opts.Name, uploadChannel)
if err != nil {
return cmdutil.WrapHTTP(err, "upload %s", path)
}
return printUploadSuccess(k, jopts, kbID, "uploaded", "Uploaded", path, opts.Name, path)
return printUploadSuccess(k, jopts, "Uploaded", opts.Name, path)
}
+6 -21
View File
@@ -63,24 +63,12 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
}
if len(matches) == 0 {
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.Success(
recursiveResult{KBID: kbID}, &format.Meta{KBID: kbID}), jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, recursiveResult{KBID: kbID}, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "(no files matched %q under %s)\n", opts.Glob, dir)
return nil
}
if opts.DryRun {
previews := make([]uploadOutcome, 0, len(matches))
for _, m := range matches {
previews = append(previews, uploadOutcome{Path: m})
}
return cmdutil.EmitDryRun(jopts.Enabled(),
recursiveResult{KBID: kbID, Uploaded: previews},
&format.Meta{KBID: kbID},
&format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("upload %d file(s) to kb %s", len(matches), kbID)})
}
var uploaded, failed []uploadOutcome
var firstFailCode cmdutil.ErrorCode
for _, p := range matches {
@@ -110,9 +98,7 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
if jopts.Enabled() {
result := recursiveResult{KBID: kbID, Uploaded: uploaded, Failed: failed}
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("upload %d file(s) to kb %s", len(matches), kbID)}
if err := format.WriteEnvelopeFiltered(iostreams.IO.Out,
format.SuccessWithRisk(result, &format.Meta{KBID: kbID}, risk), jopts.Fields, jopts.JQ); err != nil {
if err := format.WriteJSONFiltered(iostreams.IO.Out, result, jopts.Fields, jopts.JQ); err != nil {
return err
}
} else {
@@ -120,11 +106,10 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
}
if len(failed) > 0 {
// Silent on the --json path: the success envelope above already
// carries per-file uploaded[]/failed[] detail. Without Silent the
// root error handler would write a second Failure envelope on
// stdout, corrupting the stream. ExitCode still walks Code so the
// typed exit-code-by-class contract is preserved.
// Silent on the --json path: the success object above already
// carries per-file uploaded[]/failed[] detail; without Silent the
// root error handler would print to stderr in addition. ExitCode
// still walks Code so the typed exit-code-by-class contract holds.
return &cmdutil.Error{
Code: firstFailCode,
Message: fmt.Sprintf("%d of %d uploads failed", len(failed), len(matches)),
+5 -16
View File
@@ -157,7 +157,7 @@ func TestUploadRecursive_RejectsNameFlag(t *testing.T) {
assert.Contains(t, typed.Message, "--name")
}
func TestUploadRecursive_JSON_Envelope(t *testing.T) {
func TestUploadRecursive_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
dir := t.TempDir()
mkTree(t, dir, "ok.pdf", "bad.pdf")
@@ -178,13 +178,13 @@ func TestUploadRecursive_JSON_Envelope(t *testing.T) {
assert.Contains(t, body, `"failed":`)
assert.Contains(t, body, `ok.pdf`)
assert.Contains(t, body, `bad.pdf`)
assert.NotContains(t, body, `"ok":`, "bare output must not carry envelope keys")
// --json must emit exactly ONE envelope. Per-file "FAIL"/"OK" progress
// lines belong on the human path; the typed error is Silent so the root
// handler doesn't write a second Failure envelope on top of ours.
// --json must emit exactly ONE JSON document. Per-file "FAIL"/"OK"
// progress lines belong on the human path; the typed error is Silent so
// the root handler doesn't write anything additional to stdout.
assert.NotContains(t, body, "FAIL ", "per-file plain lines must not appear under --json")
assert.NotContains(t, body, "OK ", "per-file plain lines must not appear under --json")
assert.Equal(t, 1, strings.Count(body, `"ok":`), "exactly one envelope on stdout")
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
@@ -192,14 +192,3 @@ func TestUploadRecursive_JSON_Envelope(t *testing.T) {
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
func TestUploadRecursive_DryRun(t *testing.T) {
out, _ := iostreams.SetForTest(t)
dir := t.TempDir()
mkTree(t, dir, "a.pdf", "b.pdf")
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*", DryRun: true}
require.NoError(t, runUploadRecursive(context.Background(), opts, nil, svc, "kb_xxx", dir))
assert.Len(t, svc.called, 0, "dry-run must not call SDK")
got := out.String()
assert.Contains(t, got, "would upload 2")
}
+6 -18
View File
@@ -103,10 +103,9 @@ func TestUpload_Success_JSON(t *testing.T) {
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.JSONOptions{}, svc, "kb_xxx", path))
got := out.String()
assert.True(t, strings.HasPrefix(got, `{"ok":true`), "envelope should start with ok:true; got %q", got)
assert.Contains(t, got, `"id":"doc_77"`)
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"doc_77"`), "expected bare Knowledge object; got %q", got)
assert.Contains(t, got, `"file_name":"a.md"`)
assert.Contains(t, got, `"kb_id":"kb_xxx"`, "_meta.kb_id should carry the resolved kb id")
assert.NotContains(t, got, `"ok":`)
}
func TestUpload_HTTPError_500(t *testing.T) {
@@ -193,27 +192,16 @@ func TestUploadFromURL_WithName_Passes_AsFileName(t *testing.T) {
"--name must be forwarded as FileName (server uses it for file-vs-crawl mode hint)")
}
func TestUploadFromURL_JSON_Envelope(t *testing.T) {
func TestUploadFromURL_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeUploadSvc{urlResp: &sdk.Knowledge{ID: "doc_url_3", FileName: "ok.pdf"}}
jopts := &cmdutil.JSONOptions{}
require.NoError(t, runUploadFromURL(context.Background(),
&UploadOptions{FromURL: "https://example.com/ok.pdf"}, jopts, svc, "kb_xxx"))
assert.Contains(t, out.String(), `"ok":true`)
assert.Contains(t, out.String(), `"id":"doc_url_3"`)
assert.Contains(t, out.String(), `"risk":{"level":"write"`)
}
func TestUploadFromURL_DryRun(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeUploadSvc{} // must not be called
jopts := &cmdutil.JSONOptions{}
opts := &UploadOptions{FromURL: "https://example.com/x.pdf", DryRun: true}
require.NoError(t, runUploadFromURL(context.Background(), opts, jopts, svc, "kb_xxx"))
got := out.String()
assert.Contains(t, got, `"dry_run":true`)
assert.Contains(t, got, `"from_url"`)
assert.Empty(t, svc.got.urlReq.URL, "SDK call must NOT fire on --dry-run")
assert.Contains(t, got, `"id":"doc_url_3"`)
assert.NotContains(t, got, `"ok":`)
assert.NotContains(t, got, `"risk":`)
}
func TestUploadFromURL_DuplicateURLMaps_resource_already_exists(t *testing.T) {
+1 -1
View File
@@ -63,7 +63,7 @@ func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions,
return cmdutil.WrapHTTP(err, "get document %q", id)
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.Success(doc, &format.Meta{KBID: doc.KnowledgeBaseID}), jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, doc, jopts.Fields, jopts.JQ)
}
w := iostreams.IO.Out
fmt.Fprintf(w, "ID: %s\n", doc.ID)
+5 -2
View File
@@ -94,14 +94,17 @@ func TestView_Human_OmitsEmptyFields(t *testing.T) {
}
}
func TestView_JSON_EmitsEnvelope(t *testing.T) {
func TestView_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_abc", FileName: "x.txt", KnowledgeBaseID: "kb1"}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.JSONOptions{}, svc, "doc_abc"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
for _, want := range []string{`"ok":true`, `"id":"doc_abc"`, `"file_name":"x.txt"`, `"knowledge_base_id":"kb1"`} {
if strings.Contains(got, `"ok":`) || strings.Contains(got, `"data":`) {
t.Errorf("bare output must not carry envelope keys: %q", got)
}
for _, want := range []string{`"id":"doc_abc"`, `"file_name":"x.txt"`, `"knowledge_base_id":"kb1"`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
+19 -25
View File
@@ -8,16 +8,15 @@
// fail — failed; "hint" actionable
// skip — cascade-skipped (prereq failed) or --offline mode
//
// Envelope (v0.2):
// - any check is fail → envelope.ok=false, exit 1 (RunE returns SilentError
// so the data envelope is still emitted; the framework's error-envelope
// printer is bypassed for this command)
// - warn only / all ok → envelope.ok=true, exit 0
// - warn does NOT flip envelope.ok
// JSON output emits the Result object directly (bare data). Exit-code
// signal:
// - any check is fail → exit 1 (RunE returns SilentError so the data
// object is still emitted)
// - warn only / all ok → exit 0
//
// data.summary.all_passed gives the agent a one-line short-circuit; v0.2
// keeps it true ONLY when no warn / fail / skip checks are present. Agents
// SHOULD also inspect data.checks[].status to distinguish warn from ok.
// summary.all_passed gives the agent a one-line short-circuit; it is true
// ONLY when no warn / fail / skip checks are present. Agents SHOULD also
// inspect checks[].status to distinguish warn from ok.
package doctor
import (
@@ -71,8 +70,8 @@ type Check struct {
// Summary is the agent-friendly short-circuit payload (spec §1.2).
//
// AllPassed is true only when there are zero warn/fail/skip rows; warn does
// not block exit-0 (envelope.ok stays true) but it does flip AllPassed so
// agents reading just the boolean still notice the soft issue.
// not block exit-0 but it does flip AllPassed so agents reading just the
// boolean still notice the soft issue.
type Summary struct {
AllPassed bool `json:"all_passed"`
Passed int `json:"passed"`
@@ -81,7 +80,7 @@ type Summary struct {
Skipped int `json:"skipped"`
}
// Result is the full envelope data.
// Result is the bare JSON payload.
type Result struct {
Summary Summary `json:"summary"`
Checks []Check `json:"checks"`
@@ -114,9 +113,9 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command {
cliVer, _, _ := build.Info()
r := runChecks(c.Context(), opts, svc, cliVer)
emit(jopts, r)
// v0.2 exit-code policy: fail → exit 1; warn / ok / skip → exit 0.
// SilentError suppresses both the human "error: ..." line and the
// error envelope printer, so the data envelope already written by
// Exit-code policy: fail → exit 1; warn / ok / skip → exit 0.
// SilentError suppresses both the human "error: ..." line and
// the stderr error formatter, so the JSON already written by
// emit() is the only stdout content.
if r.Summary.Failed > 0 {
return cmdutil.SilentError
@@ -127,7 +126,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)
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.")
aiclient.SetAgentHelp(cmd, "Returns 4 health checks as a bare JSON object {summary, checks}. AGENT short-circuit: read summary.all_passed; if false, inspect checks[].status (ok/warn/fail/skip). exit 1 only when any status=fail; warn does not affect exit.")
return cmd
}
@@ -332,17 +331,12 @@ func summarize(cs []Check) Summary {
return s
}
// emit renders the doctor result. JSON path constructs the envelope directly
// rather than calling format.Success because envelope.ok must reflect "no
// fail" — warn does not flip it (per package doc), but fail does. We can't
// use format.Failure either, since that drops the data field.
// emit renders the doctor result. The JSON path emits the Result directly;
// pass/fail signaling is conveyed by summary.failed (and the process exit
// code, set by the caller).
func emit(jopts *cmdutil.JSONOptions, r Result) {
if jopts.Enabled() {
_ = format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Envelope{OK: r.Summary.Failed == 0, Data: r},
jopts.Fields, jopts.JQ,
)
_ = format.WriteJSONFiltered(iostreams.IO.Out, r, jopts.Fields, jopts.JQ)
return
}
for _, c := range r.Checks {
+25 -16
View File
@@ -79,7 +79,7 @@ func TestDoctor_AllOK(t *testing.T) {
}
emit(&cmdutil.JSONOptions{}, r)
if !strings.Contains(out.String(), `"all_passed":true`) {
t.Errorf("envelope should embed all_passed=true, got %q", out.String())
t.Errorf("bare output should embed all_passed=true, got %q", out.String())
}
}
@@ -278,14 +278,19 @@ func TestDoctor_VersionSkewWarns(t *testing.T) {
t.Error("AllPassed must be false when any check is warn")
}
// Envelope ok stays true (warn is non-blocking).
// Wire shape: warn-only run still has summary.failed=0 (bare data
// carries the signal; exit code stays 0).
out, _ := iostreams.SetForTest(t)
emit(&cmdutil.JSONOptions{}, r)
if !strings.Contains(out.String(), `"ok":true`) {
t.Errorf("envelope.ok must be true on warn-only run, got %q", out.String())
body := out.String()
if strings.Contains(body, `"ok":`) {
t.Errorf("bare doctor output must not carry envelope keys, got %q", body)
}
if !strings.Contains(out.String(), `"status":"warn"`) {
t.Errorf("envelope must surface status=warn, got %q", out.String())
if !strings.Contains(body, `"failed":0`) {
t.Errorf("bare output must carry failed:0 on warn-only run, got %q", body)
}
if !strings.Contains(body, `"status":"warn"`) {
t.Errorf("bare output must surface status=warn, got %q", body)
}
}
@@ -367,10 +372,10 @@ func TestDoctor_CredStoreFactoryError(t *testing.T) {
}
}
// TestDoctor_EmitEnvelope_OK_WhenWarnOnly pins the wire contract: warn never
// flips envelope.ok. emit() is the seam between Result and what agents
// observe; we test it in isolation rather than relying on summary fields.
func TestDoctor_EmitEnvelope_OK_WhenWarnOnly(t *testing.T) {
// TestDoctor_BareJSON_WarnDoesNotSignalFail pins the wire contract: warn
// keeps summary.failed=0 (so exit code stays 0). emit() is the seam between
// Result and what agents observe.
func TestDoctor_BareJSON_WarnDoesNotSignalFail(t *testing.T) {
out, _ := iostreams.SetForTest(t)
r := Result{
Summary: Summary{AllPassed: false, Passed: 3, Warned: 1},
@@ -383,13 +388,17 @@ func TestDoctor_EmitEnvelope_OK_WhenWarnOnly(t *testing.T) {
}
emit(&cmdutil.JSONOptions{}, r)
got := out.String()
if !strings.Contains(got, `"ok":true`) {
t.Errorf("envelope.ok must be true on warn-only result, got %q", got)
if !strings.Contains(got, `"failed":0`) {
t.Errorf("warn-only result must have summary.failed=0 (exit-0 signal), got %q", got)
}
if strings.Contains(got, `"ok":`) {
t.Errorf("bare output must not carry envelope keys, got %q", got)
}
}
// TestDoctor_EmitEnvelope_NotOK_OnFail pins the dual: any fail flips ok=false.
func TestDoctor_EmitEnvelope_NotOK_OnFail(t *testing.T) {
// TestDoctor_BareJSON_FailRaisesSummary pins the dual: any fail surfaces in
// summary.failed (caller maps that to exit 1 via SilentError).
func TestDoctor_BareJSON_FailRaisesSummary(t *testing.T) {
out, _ := iostreams.SetForTest(t)
r := Result{
Summary: Summary{AllPassed: false, Passed: 2, Failed: 1, Skipped: 1},
@@ -402,8 +411,8 @@ func TestDoctor_EmitEnvelope_NotOK_OnFail(t *testing.T) {
}
emit(&cmdutil.JSONOptions{}, r)
got := out.String()
if !strings.Contains(got, `"ok":false`) {
t.Errorf("envelope.ok must be false when any check fails, got %q", got)
if !strings.Contains(got, `"failed":1`) {
t.Errorf("fail must surface in summary.failed, got %q", got)
}
}
+1 -12
View File
@@ -31,7 +31,6 @@ type CreateOptions struct {
Name string
Description string
EmbeddingModel string
DryRun bool
}
// CreateService is the narrow SDK surface this command depends on.
@@ -52,10 +51,6 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
if err != nil {
return err
}
opts.DryRun = cmdutil.IsDryRun(c)
if opts.DryRun {
return runCreate(c.Context(), opts, jopts, nil) // service unused on dry-run
}
cli, err := f.Client()
if err != nil {
return err
@@ -86,19 +81,13 @@ func runCreate(ctx context.Context, opts *CreateOptions, jopts *cmdutil.JSONOpti
req.EmbeddingModelID = opts.EmbeddingModel
}
if opts.DryRun {
return cmdutil.EmitDryRun(jopts.Enabled(), req, nil,
&format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("create knowledge base %q", opts.Name)})
}
created, err := svc.CreateKnowledgeBase(ctx, req)
if err != nil {
return cmdutil.WrapHTTP(err, "create knowledge base")
}
if jopts.Enabled() {
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("created knowledge base %s", created.ID)}
return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.SuccessWithRisk(created, &format.Meta{KBID: created.ID}, risk), jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, created, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Created knowledge base %q (id: %s)\n", created.Name, created.ID)
return nil
+2 -3
View File
@@ -117,8 +117,7 @@ func TestCreate_JSONOutput(t *testing.T) {
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
got := out.String()
assert.True(t, strings.HasPrefix(got, `{"ok":true`), "envelope should start with ok:true; got %q", got)
assert.Contains(t, got, `"id":"kb_99"`)
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"kb_99"`), "expected bare KnowledgeBase object; got %q", got)
assert.Contains(t, got, `"name":"Eng"`)
assert.Contains(t, got, `"kb_id":"kb_99"`, "_meta.kb_id should carry the new id")
assert.NotContains(t, got, `"ok":`)
}
+2 -14
View File
@@ -18,8 +18,7 @@ import (
var kbDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {
Yes bool // sourced from the global -y/--yes persistent flag (see cli/cmd/root.go addGlobalFlags)
DryRun bool
Yes bool // sourced from the global -y/--yes persistent flag (see cli/cmd/root.go addGlobalFlags)
}
// DeleteService is the narrow SDK surface this command depends on.
@@ -61,10 +60,6 @@ guard against unintended deletes.`,
return err
}
opts.Yes, _ = c.Flags().GetBool("yes")
opts.DryRun = cmdutil.IsDryRun(c)
if opts.DryRun {
return runDelete(c.Context(), opts, jopts, nil, f.Prompter(), args[0])
}
cli, err := f.Client()
if err != nil {
return err
@@ -78,12 +73,6 @@ guard against unintended deletes.`,
}
func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOptions, svc DeleteService, p prompt.Prompter, id string) error {
if opts.DryRun {
return cmdutil.EmitDryRun(jopts.Enabled(),
deleteResult{ID: id, Deleted: false}, &format.Meta{KBID: id},
&format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("delete knowledge base %s", id)})
}
if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "knowledge base", id); err != nil {
return err
}
@@ -93,8 +82,7 @@ func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOpti
}
if jopts.Enabled() {
risk := &format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("deleted knowledge base %s", id)}
return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.SuccessWithRisk(deleteResult{ID: id, Deleted: true}, &format.Meta{KBID: id}, risk), jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted knowledge base %s\n", id)
return nil
+2 -3
View File
@@ -93,10 +93,9 @@ func TestDelete_JSONOutput(t *testing.T) {
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, p, "kb_json"))
got := out.String()
assert.True(t, strings.HasPrefix(got, `{"ok":true`), "envelope should start with ok:true; got %q", got)
assert.Contains(t, got, `"id":"kb_json"`)
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"kb_json"`), "expected bare object; got %q", got)
assert.Contains(t, got, `"deleted":true`)
assert.Contains(t, got, `"kb_id":"kb_json"`)
assert.NotContains(t, got, `"ok":`)
}
// The remaining tests cover the interactive confirm path which only fires
+1 -20
View File
@@ -33,7 +33,6 @@ type EditOptions struct {
// clear the description.
Name *string
Description *string
DryRun bool
}
// EditService is the narrow SDK surface this command depends on. GetKnowledgeBase
@@ -66,10 +65,6 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command {
if c.Flag("description").Changed {
opts.Description = &desc
}
opts.DryRun = cmdutil.IsDryRun(c)
if opts.DryRun {
return runEdit(c.Context(), opts, jopts, nil, args[0])
}
cli, err := f.Client()
if err != nil {
return err
@@ -93,20 +88,6 @@ func runEdit(ctx context.Context, opts *EditOptions, jopts *cmdutil.JSONOptions,
}
}
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("edit knowledge base %s", id)}
if opts.DryRun {
// Dry-run renders only the user-set fields so the preview reflects
// intent; the real-run fetch path fills in the rest from the server.
preview := &sdk.UpdateKnowledgeBaseRequest{}
if opts.Name != nil {
preview.Name = *opts.Name
}
if opts.Description != nil {
preview.Description = *opts.Description
}
return cmdutil.EmitDryRun(jopts.Enabled(), preview, &format.Meta{KBID: id}, risk)
}
// Fetch current state so we can fill in fields the user didn't touch.
// TOCTOU note: another writer could change Name/Description between
// our Get and Put; matches the same race window kb pin / unpin have.
@@ -130,7 +111,7 @@ func runEdit(ctx context.Context, opts *EditOptions, jopts *cmdutil.JSONOptions,
return cmdutil.WrapHTTP(err, "edit knowledge base %s", id)
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.SuccessWithRisk(updated, &format.Meta{KBID: id}, risk), jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, updated, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Updated knowledge base %s\n", id)
return nil
-13
View File
@@ -3,7 +3,6 @@ package kb
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -102,18 +101,6 @@ func TestEdit_BothFlags(t *testing.T) {
assert.Equal(t, "new desc", svc.gotReq.Description)
}
func TestEdit_DryRun_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
opts := &EditOptions{DryRun: true}
opts.Name = stringPtr("preview")
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, nil, "kb_abc"))
body := out.String()
assert.True(t, strings.HasPrefix(body, `{"ok":true`))
assert.Contains(t, body, `"dry_run":true`)
assert.Contains(t, body, `"write"`)
}
func TestEdit_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
// 404 must come from the GetKnowledgeBase pre-fetch in the fetch-then-
+3 -15
View File
@@ -19,8 +19,7 @@ import (
var kbEmptyFields = []string{"id", "deleted_count"}
type EmptyOptions struct {
Yes bool
DryRun bool
Yes bool
}
type EmptyService interface {
@@ -57,10 +56,6 @@ piped contexts. Without -y the CLI exits 10 in non-interactive mode.`,
return err
}
opts.Yes, _ = c.Flags().GetBool("yes")
opts.DryRun = cmdutil.IsDryRun(c)
if opts.DryRun {
return runEmpty(c.Context(), opts, jopts, nil, f.Prompter(), args[0])
}
cli, err := f.Client()
if err != nil {
return err
@@ -74,12 +69,6 @@ piped contexts. Without -y the CLI exits 10 in non-interactive mode.`,
}
func runEmpty(ctx context.Context, opts *EmptyOptions, jopts *cmdutil.JSONOptions, svc EmptyService, p prompt.Prompter, id string) error {
risk := &format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("empty knowledge base %s", id)}
if opts.DryRun {
return cmdutil.EmitDryRun(jopts.Enabled(), emptyResult{ID: id}, &format.Meta{KBID: id}, risk)
}
if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "all contents of knowledge base", id); err != nil {
return err
}
@@ -94,9 +83,8 @@ func runEmpty(ctx context.Context, opts *EmptyOptions, jopts *cmdutil.JSONOption
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.SuccessWithRisk(
emptyResult{ID: id, DeletedCount: deleted}, &format.Meta{KBID: id}, risk,
), jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out,
emptyResult{ID: id, DeletedCount: deleted}, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Emptied knowledge base %s (%d document(s) cleared)\n", id, deleted)
return nil
-11
View File
@@ -3,7 +3,6 @@ package kb
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -81,13 +80,3 @@ func TestEmpty_NotFound(t *testing.T) {
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestEmpty_DryRun_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeEmptySvc{}
require.NoError(t, runEmpty(context.Background(), &EmptyOptions{DryRun: true}, &cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{}, "kb_dry"))
body := out.String()
assert.True(t, strings.HasPrefix(body, `{"ok":true`))
assert.Contains(t, body, `"dry_run":true`)
assert.Contains(t, body, `"high-risk-write"`)
assert.False(t, svc.called)
}
+2 -11
View File
@@ -44,11 +44,6 @@ type ListService interface {
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
}
// listResult is the typed payload emitted under data.items.
type listResult struct {
Items []sdk.KnowledgeBase `json:"items"`
}
// NewCmdList builds `weknora kb list`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
@@ -71,7 +66,7 @@ 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)
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.")
aiclient.SetAgentHelp(cmd, "Lists all knowledge bases as a bare JSON array of {id, name, ...} objects (empty `[]` when none). --pinned restricts to pinned KBs (client-side filter). --limit caps the returned slice. Use `--json` (bare) for full objects, `--json id,name` to project fields, or `--jq` for arbitrary reshape.")
return cmd
}
@@ -110,11 +105,7 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(listResult{Items: items}, nil),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, items, jopts.Fields, jopts.JQ)
}
if len(items) == 0 {
+9 -17
View File
@@ -39,12 +39,9 @@ func TestList_Empty_JSON(t *testing.T) {
if err := runList(context.Background(), &ListOptions{}, jopts, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
t.Fatalf("runList: %v", err)
}
got := out.String()
if !strings.Contains(got, `"items":[]`) {
t.Errorf("empty JSON should contain items:[], got %q", got)
}
if strings.Contains(got, `"items":null`) {
t.Error("items must be [] not null")
got := strings.TrimSpace(out.String())
if got != "[]" {
t.Errorf("empty JSON should be bare `[]`, got %q", got)
}
}
@@ -76,19 +73,14 @@ func TestList_JSON_FieldFilter(t *testing.T) {
if err := runList(context.Background(), &ListOptions{}, jopts, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
var got []map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v\n%s", err, out.String())
}
if len(env.Data.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(env.Data.Items))
if len(got) != 1 {
t.Fatalf("expected 1 item, got %d", len(got))
}
item := env.Data.Items[0]
item := got[0]
if item["id"] != "kb1" || item["name"] != "Marketing" {
t.Errorf("kept fields wrong: %+v", item)
}
@@ -104,7 +96,7 @@ func TestList_JSON_JQ(t *testing.T) {
{ID: "kb1", Name: "Marketing", UpdatedAt: now},
{ID: "kb2", Name: "Engineering", UpdatedAt: now.Add(-time.Hour)},
}
jopts := &cmdutil.JSONOptions{JQ: ".data.items | length"}
jopts := &cmdutil.JSONOptions{JQ: ". | length"}
if err := runList(context.Background(), &ListOptions{}, jopts, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
+7 -25
View File
@@ -18,9 +18,7 @@ import (
// relevant fields here are the id and the new pin state.
var kbPinFields = []string{"id", "is_pinned"}
type PinOptions struct {
DryRun bool
}
type PinOptions struct{}
// PinService is the narrow SDK surface this command depends on. The CLI
// reads current state before toggling so `pin`/`unpin` are idempotent —
@@ -51,10 +49,6 @@ func newPinCmd(f *cmdutil.Factory, use string, want bool, short string) *cobra.C
if err != nil {
return err
}
opts.DryRun = cmdutil.IsDryRun(c)
if opts.DryRun {
return runPin(c.Context(), opts, jopts, nil, args[0], want)
}
cli, err := f.Client()
if err != nil {
return err
@@ -72,18 +66,6 @@ func runPin(ctx context.Context, opts *PinOptions, jopts *cmdutil.JSONOptions, s
if !want {
verb = "unpin"
}
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("%s knowledge base %s", verb, id)}
if opts.DryRun {
// Dry-run can't introspect state without a network call by design (see
// kb/delete.go for the same convention). Report what *would* run if
// state diverged; agents can disambiguate via a subsequent `kb view`.
return cmdutil.EmitDryRun(jopts.Enabled(), struct {
ID string `json:"id"`
Want bool `json:"want_pinned"`
}{id, want}, &format.Meta{KBID: id}, risk)
}
current, err := svc.GetKnowledgeBase(ctx, id)
if err != nil {
return cmdutil.WrapHTTP(err, "get knowledge base %s", id)
@@ -93,12 +75,12 @@ func runPin(ctx context.Context, opts *PinOptions, jopts *cmdutil.JSONOptions, s
if !want {
state = "unpinned"
}
// No-op path: tell agents what happened. The risk-write classification
// was the *requested* operation, not what occurred — surface it via a
// _meta.warning so audit logs don't count a write that wasn't made.
// No-op path: the resource is already in the requested state. We
// emit the current resource so callers see the canonical shape on
// both fresh-toggle and no-op paths. Human path prints a confirming
// line; agents observe via the unchanged is_pinned field.
if jopts.Enabled() {
meta := &format.Meta{KBID: id, Warnings: []string{fmt.Sprintf("already %s — no server call made", state)}}
return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.Success(current, meta), jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, current, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "✓ %s is already %s\n", id, state)
return nil
@@ -109,7 +91,7 @@ func runPin(ctx context.Context, opts *PinOptions, jopts *cmdutil.JSONOptions, s
return cmdutil.WrapHTTP(err, "%s knowledge base %s", verb, id)
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.SuccessWithRisk(updated, &format.Meta{KBID: id}, risk), jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, updated, jopts.Fields, jopts.JQ)
}
state := "pinned"
if !updated.IsPinned {
-10
View File
@@ -96,16 +96,6 @@ func TestPin_ToggleError(t *testing.T) {
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
func TestPin_DryRun_StateDiffers(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}}
require.NoError(t, runPin(context.Background(), &PinOptions{DryRun: true}, &cmdutil.JSONOptions{}, svc, "kb_abc", true))
assert.False(t, svc.toggleCalled, "dry-run must not call toggle")
body := out.String()
assert.Contains(t, body, `"dry_run":true`)
assert.Contains(t, body, `"write"`)
}
func TestPin_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}}
+1 -1
View File
@@ -63,7 +63,7 @@ func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions,
return cmdutil.WrapHTTP(err, "get knowledge base %q", id)
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(iostreams.IO.Out, format.Success(kb, nil), jopts.Fields, jopts.JQ)
return format.WriteJSONFiltered(iostreams.IO.Out, kb, jopts.Fields, jopts.JQ)
}
// Human: KEY: VALUE
w := iostreams.IO.Out
+4 -4
View File
@@ -43,11 +43,11 @@ func TestGet_OK_JSON(t *testing.T) {
t.Fatalf("runGet: %v", err)
}
got := out.String()
if !strings.Contains(got, `"ok":true`) {
t.Errorf("expected ok:true in %q", got)
if !strings.HasPrefix(strings.TrimSpace(got), `{"id":"kb1"`) {
t.Errorf("expected bare object starting with id, got %q", got)
}
if !strings.Contains(got, `"id":"kb1"`) {
t.Errorf("expected id field in %q", got)
if strings.Contains(got, `"ok":true`) {
t.Errorf("bare output must not carry envelope keys, got %q", got)
}
}
+1 -5
View File
@@ -105,11 +105,7 @@ func runLink(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, f *
ProjectLinkPath: linkPath,
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(r, &format.Meta{Context: ctxName, KBID: kbID}),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, r, jopts.Fields, jopts.JQ)
}
if kbName != "" {
fmt.Fprintf(iostreams.IO.Out, "✓ Linked %s to %s (kb=%s, id=%s)\n", linkPath, ctxName, kbName, kbID)
+2 -5
View File
@@ -74,11 +74,8 @@ func runUnlink(opts *UnlinkOptions, jopts *cmdutil.JSONOptions) error {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "remove %s", linkPath)
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(unlinkResult{ProjectLinkPath: linkPath}, nil),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out,
unlinkResult{ProjectLinkPath: linkPath}, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Unlinked %s\n", linkPath)
return nil
+6 -3
View File
@@ -79,7 +79,7 @@ func TestUnlink_NoLink_Errors(t *testing.T) {
}
}
func TestUnlink_JSON_Envelope(t *testing.T) {
func TestUnlink_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
tmp := t.TempDir()
mkLinkFile(t, tmp)
@@ -89,9 +89,12 @@ func TestUnlink_JSON_Envelope(t *testing.T) {
t.Fatalf("runUnlink: %v", err)
}
got := out.String()
for _, want := range []string{`"ok":true`, `"project_link_path"`, projectlink.DirName} {
for _, want := range []string{`"project_link_path"`, projectlink.DirName} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in envelope:\n%s", want, got)
t.Errorf("missing %q in output:\n%s", want, got)
}
}
if strings.Contains(got, `"ok":`) {
t.Errorf("bare output must not carry envelope keys, got %q", got)
}
}
+3 -4
View File
@@ -189,7 +189,6 @@ func addGlobalFlags(cmd *cobra.Command) {
pf := cmd.PersistentFlags()
pf.BoolP("yes", "y", false, "Skip confirmation prompts on destructive operations")
pf.String("context", "", "Override the active context for this invocation (no disk write)")
pf.Bool("dry-run", false, "Preview the operation without executing (write commands only; read commands ignore)")
}
// agentAwareHelpFunc wraps cobra's default help to append the AI agent guidance
@@ -231,13 +230,13 @@ func newVersionCmd(f *cmdutil.Factory) *cobra.Command {
}
v, commit, date := build.Info()
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(
return format.WriteJSONFiltered(
c.OutOrStdout(),
format.Success(map[string]string{
map[string]string{
"version": v,
"commit": commit,
"date": date,
}, nil),
},
jopts.Fields, jopts.JQ,
)
}
+4 -2
View File
@@ -31,8 +31,10 @@ func TestVersion_JSON(t *testing.T) {
root.SetOut(&out)
require.NoError(t, root.Execute())
got := out.String()
assert.True(t, strings.HasPrefix(got, `{"ok":true`), "got: %q", got)
assert.Contains(t, got, "version")
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{`), "expected bare JSON object, got: %q", got)
assert.Contains(t, got, `"version":"`)
assert.NotContains(t, got, `"ok":`)
assert.NotContains(t, got, `"data":`)
}
// Smoke test for cmdutil.ExitCode wiring; full coverage lives in
+6 -11
View File
@@ -15,7 +15,8 @@ import (
)
// chunksFields enumerates the fields surfaced for `--json` discovery on
// `search chunks`. Lists data.items[*] (SearchResult) fields.
// `search chunks`. Filter applies to each SearchResult object in the bare
// array.
var chunksFields = []string{
"id", "content", "knowledge_id", "chunk_index", "knowledge_title",
"start_at", "end_at", "seq", "score", "match_type", "chunk_type",
@@ -34,11 +35,6 @@ type ChunksOptions struct {
NoKeyword bool
}
// chunksResult is the typed payload emitted under data.items.
type chunksResult struct {
Items []*sdk.SearchResult `json:"items"`
}
// ChunksService is the narrow SDK surface used by runChunks. *sdk.Client
// satisfies it; tests inject fakes via Factory.Client.
type ChunksService interface {
@@ -145,11 +141,10 @@ func runChunks(ctx context.Context, opts *ChunksOptions, jopts *cmdutil.JSONOpti
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(chunksResult{Items: results}, &format.Meta{KBID: opts.KBID}),
jopts.Fields, jopts.JQ,
)
if results == nil {
results = []*sdk.SearchResult{}
}
return format.WriteJSONFiltered(iostreams.IO.Out, results, jopts.Fields, jopts.JQ)
}
return renderChunkResults(results, opts.KBID)
}
+4 -2
View File
@@ -61,8 +61,10 @@ func TestRunSearch_JSONOutput(t *testing.T) {
svc := &fakeChunksSvc{results: []*sdk.SearchResult{{Score: 0.9, Content: "x"}}}
opts := &ChunksOptions{Query: "q", KBID: "kb1", Limit: 1}
require.NoError(t, runChunks(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
assert.True(t, strings.HasPrefix(out.String(), `{"ok":true`), "got: %q", out.String())
assert.Contains(t, out.String(), `"kb_id":"kb1"`)
got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), "["), "expected bare JSON array, got: %q", got)
assert.NotContains(t, got, `"ok":`)
assert.Contains(t, got, `"score":0.9`)
}
func TestRunSearch_EmptyResults(t *testing.T) {
+1 -10
View File
@@ -39,11 +39,6 @@ type DocsSearchOptions struct {
Limit int
}
// docsResult is the typed payload emitted under data.items.
type docsResult struct {
Items []sdk.Knowledge `json:"items"`
}
// DocsSearchService is the narrow SDK surface this command depends on.
// Server has no fuzzy-document-name endpoint, so the CLI pages through
// ListKnowledge and filters by Title / FileName client-side.
@@ -125,11 +120,7 @@ done:
if matches == nil {
matches = []sdk.Knowledge{}
}
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(docsResult{Items: matches}, &format.Meta{KBID: opts.KBID}),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, matches, jopts.Fields, jopts.JQ)
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
+5 -6
View File
@@ -2,8 +2,8 @@ package search
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
@@ -11,7 +11,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
@@ -97,10 +96,10 @@ func TestDocsSearch_JSON(t *testing.T) {
total: 1,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "match", KBID: "kb1", Limit: 20}, &cmdutil.JSONOptions{}, svc))
var env format.Envelope
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
require.True(t, env.OK)
assert.Contains(t, out.String(), `"id":"d1"`)
got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), "["), "expected bare JSON array, got: %q", got)
assert.Contains(t, got, `"id":"d1"`)
assert.NotContains(t, got, `"ok":`)
}
func TestDocsSearch_NetworkError(t *testing.T) {
+1 -10
View File
@@ -33,11 +33,6 @@ type KBSearchOptions struct {
Limit int
}
// kbSearchResult is the typed payload emitted under data.items.
type kbSearchResult struct {
Items []sdk.KnowledgeBase `json:"items"`
}
// KBSearchService is the narrow SDK surface this command depends on.
// Server has no fuzzy-KB-name endpoint; the CLI filters ListKnowledgeBases
// client-side. Acceptable because tenants typically have ≪ 1000 KBs.
@@ -96,11 +91,7 @@ func runKBSearch(ctx context.Context, opts *KBSearchOptions, jopts *cmdutil.JSON
if matches == nil {
matches = []sdk.KnowledgeBase{}
}
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(kbSearchResult{Items: matches}, nil),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, matches, jopts.Fields, jopts.JQ)
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
+4 -6
View File
@@ -2,7 +2,6 @@ package search
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -11,7 +10,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
@@ -103,10 +101,10 @@ func TestKBSearch_JSON(t *testing.T) {
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{{ID: "kb1", Name: "marketing"}}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, &cmdutil.JSONOptions{}, svc))
var env format.Envelope
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
require.True(t, env.OK)
assert.Contains(t, out.String(), `"id":"kb1"`)
got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), "["), "expected bare JSON array, got: %q", got)
assert.Contains(t, got, `"id":"kb1"`)
assert.NotContains(t, got, `"ok":`)
}
func TestKBSearch_NetworkError(t *testing.T) {
+1 -10
View File
@@ -30,11 +30,6 @@ type SessionsSearchOptions struct {
Limit int
}
// sessionsSearchResult is the typed payload emitted under data.items.
type sessionsSearchResult struct {
Items []sdk.Session `json:"items"`
}
// SessionsSearchService is the narrow SDK surface this command depends on.
// Server has no session-search endpoint; CLI pages through and filters by
// Title / Description client-side.
@@ -105,11 +100,7 @@ done:
if matches == nil {
matches = []sdk.Session{}
}
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(sessionsSearchResult{Items: matches}, nil),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, matches, jopts.Fields, jopts.JQ)
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
+2 -18
View File
@@ -18,8 +18,7 @@ import (
var sessionDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {
Yes bool // sourced from the global -y/--yes persistent flag
DryRun bool
Yes bool // sourced from the global -y/--yes persistent flag
}
// DeleteService is the narrow SDK surface this command depends on.
@@ -54,14 +53,10 @@ without the user's explicit go-ahead.`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Yes, _ = c.Flags().GetBool("yes")
opts.DryRun = cmdutil.IsDryRun(c)
jopts, err := cmdutil.CheckJSONFlags(c)
if err != nil {
return err
}
if opts.DryRun {
return runDelete(c.Context(), opts, jopts, nil, f.Prompter(), args[0])
}
cli, err := f.Client()
if err != nil {
return err
@@ -75,13 +70,6 @@ without the user's explicit go-ahead.`,
}
func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOptions, svc DeleteService, p prompt.Prompter, id string) error {
risk := &format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("delete session %s", id)}
if opts.DryRun {
return cmdutil.EmitDryRun(jopts.Enabled(),
deleteResult{ID: id, Deleted: false}, nil, risk)
}
if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "session", id); err != nil {
return err
}
@@ -91,11 +79,7 @@ func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOpti
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.SuccessWithRisk(deleteResult{ID: id, Deleted: true}, nil, risk),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, jopts.Fields, jopts.JQ)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted session %s\n", id)
return nil
-11
View File
@@ -3,7 +3,6 @@ package sessioncmd
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -82,13 +81,3 @@ func TestDelete_TTY_ConfirmNo(t *testing.T) {
assert.Contains(t, errBuf.String(), "Aborted")
}
func TestDelete_DryRun_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
require.NoError(t, runDelete(context.Background(), &DeleteOptions{DryRun: true}, &cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{}, "s_dry"))
body := out.String()
assert.True(t, strings.HasPrefix(body, `{"ok":true`))
assert.Contains(t, body, `"dry_run":true`)
assert.Contains(t, body, `"high-risk-write"`)
assert.False(t, svc.called, "dry-run must not call SDK")
}
+3 -21
View File
@@ -46,11 +46,6 @@ type ListService interface {
GetSessionsByTenant(ctx context.Context, page, pageSize int) ([]sdk.Session, int, error)
}
// listResult is the typed payload emitted under data.
type listResult struct {
Items []sdk.Session `json:"items"`
}
// NewCmdList builds `weknora session list`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{PageSize: defaultPageSize}
@@ -75,7 +70,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
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)
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.")
aiclient.SetAgentHelp(cmd, "Lists chat sessions as a bare JSON array of Session objects (empty `[]` when none). --all-pages drains every server page in one call (capped by --limit). --since filters client-side after fetch.")
return cmd
}
@@ -154,23 +149,10 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
if opts.Limit > 0 && len(items) > opts.Limit {
items = items[:opts.Limit]
}
_ = total // pagination metadata no longer surfaced; --all-pages drains for callers who need everything
if jopts.Enabled() {
// 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,
format.Success(listResult{Items: items}, meta),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, items, jopts.Fields, jopts.JQ)
}
if len(items) == 0 {
+9 -31
View File
@@ -2,7 +2,6 @@ package sessioncmd
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -13,7 +12,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
@@ -58,7 +56,7 @@ func TestList_Table(t *testing.T) {
assert.Equal(t, 30, svc.gotPageSize)
}
func TestList_JSON_WithMeta(t *testing.T) {
func TestList_JSON_BareArray(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListService{
items: []sdk.Session{
@@ -68,41 +66,23 @@ func TestList_JSON_WithMeta(t *testing.T) {
}
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 is server-internal: CLI always asks for page 1 of size --page-size.
// CLI always asks for page 1 of size --page-size; pagination is server-internal.
assert.Equal(t, 1, svc.gotPage)
assert.Equal(t, 10, svc.gotPageSize)
// envelope.data.items shaped + paging metadata in _meta
body := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(body), `[`), "bare array expected; got %q", body)
assert.Contains(t, body, `"id":"s_1"`)
assert.Contains(t, body, `"items":`)
// 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`)
assert.NotContains(t, body, `"ok":`)
assert.NotContains(t, body, `"_meta":`)
assert.NotContains(t, body, `"has_more":`)
assert.NotContains(t, body, `"total":`)
}
func TestList_JSON_PageSizeCoversAll_NoHasMore(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListService{
items: []sdk.Session{{ID: "s_1"}},
total: 1,
}
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`)
}
func TestList_NilItems_RendersAsEmptyArray(t *testing.T) {
func TestList_NilItems_RendersAsBareEmptyArray(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListService{items: nil, total: 0}
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30}, &cmdutil.JSONOptions{}, svc))
assert.Contains(t, out.String(), `"items":[]`)
assert.Equal(t, "[]", strings.TrimSpace(out.String()))
}
func TestList_BadPagination(t *testing.T) {
@@ -270,8 +250,6 @@ func TestList_AllPages_WalksAllServerPages(t *testing.T) {
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) {
+1 -5
View File
@@ -60,11 +60,7 @@ func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions,
return cmdutil.WrapHTTP(err, "get session %q", id)
}
if jopts.Enabled() {
return format.WriteEnvelopeFiltered(
iostreams.IO.Out,
format.Success(s, nil),
jopts.Fields, jopts.JQ,
)
return format.WriteJSONFiltered(iostreams.IO.Out, s, jopts.Fields, jopts.JQ)
}
w := iostreams.IO.Out
fmt.Fprintf(w, "ID: %s\n", s.ID)
+2 -6
View File
@@ -2,7 +2,6 @@ package sessioncmd
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -11,7 +10,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
@@ -50,11 +48,9 @@ func TestView_JSON(t *testing.T) {
svc := &fakeViewService{s: &sdk.Session{ID: "s_abc", Title: "T", UpdatedAt: "2026-05-12T14:00:00Z"}}
require.NoError(t, runView(context.Background(), &ViewOptions{}, &cmdutil.JSONOptions{}, svc, "s_abc"))
var env format.Envelope
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
require.True(t, env.OK)
body := out.String()
assert.Contains(t, body, `"id":"s_abc"`)
assert.True(t, strings.HasPrefix(strings.TrimSpace(body), `{"id":"s_abc"`), "bare object expected; got %q", body)
assert.NotContains(t, body, `"ok":`)
}
func TestView_NotFound(t *testing.T) {
-43
View File
@@ -1,43 +0,0 @@
package cmdutil
import (
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// IsDryRun reports whether the global --dry-run flag was set on cmd or any
// of its parents. Write commands check this and skip the SDK call, returning
// an envelope with dry_run=true that describes the action that would have
// run. Read commands ignore --dry-run (no side effect to preview).
func IsDryRun(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
v, _ := cmd.Flags().GetBool("dry-run")
return v
}
// EmitDryRun writes the canonical preview envelope for a write command. JSON
// mode emits SuccessWithRisk + DryRun=true; human mode prints the
// `[dry-run] would <risk.Action>` line to stdout, with " (high-risk)"
// appended automatically when risk.Level == RiskHighRiskWrite. Centralized
// so wire shape stays identical across kb create/delete, doc upload/delete,
// api, and any future write command.
func EmitDryRun(jsonOut bool, data any, meta *format.Meta, risk *format.Risk) error {
out := iostreams.IO.Out
if jsonOut {
env := format.SuccessWithRisk(data, meta, risk)
env.DryRun = true
return format.WriteEnvelope(out, env)
}
line := risk.Action
if risk.Level == format.RiskHighRiskWrite {
line += " (high-risk)"
}
_, err := fmt.Fprintf(out, "[dry-run] would %s\n", line)
return err
}
+82
View File
@@ -0,0 +1,82 @@
package format
import (
"bytes"
"encoding/json"
"io"
)
// WriteJSON serializes v as one-line JSON to w. Bare-data contract: no
// envelope wrapper. Each list command emits its array directly, each
// single-resource command emits its object directly. The shape is whatever
// the producing command marshals.
func WriteJSON(w io.Writer, v any) error {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(v)
}
// WriteJSONFiltered serializes v to w with optional field restriction and
// optional jq evaluation.
//
// - len(fields) == 0 → no field filter
// - jqExpr == "" → no jq filter
//
// Field filter rules (mirrors gh CLI's `--json field,field` semantics):
//
// - v marshals to a top-level array → each [*] object is restricted to
// the named keys
// - v marshals to a top-level object → the object is restricted to the
// named keys
// - v marshals to a scalar → unchanged
//
// Unknown field names are silently dropped so users can pass an aspirational
// field set across heterogenous list outputs without per-command tailoring
// (same policy as the old envelope filter).
func WriteJSONFiltered(w io.Writer, v any, fields []string, jqExpr string) error {
raw, err := marshalJSON(v)
if err != nil {
return err
}
if len(fields) > 0 {
raw, err = applyBareFieldFilter(raw, fields)
if err != nil {
return err
}
}
if jqExpr != "" {
return writeJQ(w, raw, jqExpr)
}
_, err = w.Write(raw)
return err
}
// marshalJSON encodes v to a newline-terminated byte slice using the same
// encoder settings as WriteJSON (HTML escaping disabled).
func marshalJSON(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// applyBareFieldFilter dispatches on the JSON shape of raw and restricts
// elements / object keys to the named fields.
func applyBareFieldFilter(raw []byte, fields []string) ([]byte, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
return raw, nil
}
switch trimmed[0] {
case '[':
return filterArrayItems(raw, fields)
case '{':
return filterObjectKeys(raw, fields)
default:
return raw, nil
}
}
+139
View File
@@ -0,0 +1,139 @@
package format_test
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/format"
)
func TestWriteJSON_BareArray(t *testing.T) {
buf := &bytes.Buffer{}
if err := format.WriteJSON(buf, []map[string]string{
{"id": "1", "name": "alpha"},
{"id": "2", "name": "beta"},
}); err != nil {
t.Fatalf("err = %v", err)
}
if !bytes.HasPrefix(buf.Bytes(), []byte("[")) {
t.Errorf("expected bare JSON array, got %q", buf.String())
}
}
func TestWriteJSON_BareObject(t *testing.T) {
buf := &bytes.Buffer{}
if err := format.WriteJSON(buf, map[string]any{"id": "kb_x", "name": "Engineering"}); err != nil {
t.Fatalf("err = %v", err)
}
if !bytes.HasPrefix(buf.Bytes(), []byte("{")) {
t.Errorf("expected bare JSON object, got %q", buf.String())
}
if bytes.Contains(buf.Bytes(), []byte(`"ok":`)) || bytes.Contains(buf.Bytes(), []byte(`"data":`)) {
t.Errorf("bare output must not carry envelope keys: %s", buf.String())
}
}
func TestWriteJSONFiltered_FieldsOnArray(t *testing.T) {
buf := &bytes.Buffer{}
items := []map[string]any{
{"id": "1", "name": "alpha", "kb_id": "kb_x", "updated_at": "2026-01-01"},
{"id": "2", "name": "beta", "kb_id": "kb_x", "updated_at": "2026-01-02"},
}
if err := format.WriteJSONFiltered(buf, items, []string{"id", "name"}, ""); err != nil {
t.Fatalf("err = %v", err)
}
var got []map[string]string
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("parse: %v\n%s", err, buf.String())
}
if len(got) != 2 {
t.Fatalf("items len = %d, want 2", len(got))
}
for i, item := range got {
if _, has := item["kb_id"]; has {
t.Errorf("item[%d] should not have kb_id: %v", i, item)
}
if item["id"] == "" || item["name"] == "" {
t.Errorf("item[%d] missing kept fields: %v", i, item)
}
}
}
func TestWriteJSONFiltered_FieldsOnObject(t *testing.T) {
buf := &bytes.Buffer{}
obj := map[string]any{"id": "kb_x", "name": "Engineering", "owner": "alice"}
if err := format.WriteJSONFiltered(buf, obj, []string{"id", "name"}, ""); err != nil {
t.Fatalf("err = %v", err)
}
var got map[string]string
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("parse: %v\n%s", err, buf.String())
}
if _, has := got["owner"]; has {
t.Errorf("should not retain owner: %v", got)
}
if got["id"] != "kb_x" || got["name"] != "Engineering" {
t.Errorf("kept fields missing: %v", got)
}
}
func TestWriteJSONFiltered_UnknownFieldSilent(t *testing.T) {
buf := &bytes.Buffer{}
if err := format.WriteJSONFiltered(buf, map[string]any{"id": "1"}, []string{"id", "nonexistent"}, ""); err != nil {
t.Fatalf("err = %v", err)
}
var got map[string]any
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if got["id"] != "1" {
t.Errorf("id missing: %v", got)
}
if _, has := got["nonexistent"]; has {
t.Errorf("nonexistent should be silently dropped: %v", got)
}
}
func TestWriteJSONFiltered_JQOnly(t *testing.T) {
buf := &bytes.Buffer{}
items := []map[string]any{
{"id": "1", "name": "alpha"},
{"id": "2", "name": "beta"},
}
if err := format.WriteJSONFiltered(buf, items, nil, ".[].id"); err != nil {
t.Fatalf("err = %v", err)
}
// gh CLI parity: string results render without JSON quotes.
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 || lines[0] != "1" || lines[1] != "2" {
t.Errorf("jq output mismatch: %q", buf.String())
}
}
func TestWriteJSONFiltered_FieldsAndJQ(t *testing.T) {
buf := &bytes.Buffer{}
items := []map[string]any{
{"id": "1", "name": "alpha", "secret": "drop-me"},
{"id": "2", "name": "beta", "secret": "drop-me"},
}
// Field filter first → then jq selects from filtered shape.
if err := format.WriteJSONFiltered(buf, items, []string{"id"}, ".[].id"); err != nil {
t.Fatalf("err = %v", err)
}
out := buf.String()
if strings.Contains(out, "drop-me") {
t.Errorf("field filter must drop unrequested keys before jq: %q", out)
}
}
func TestWriteJSONFiltered_NilDataPassthrough(t *testing.T) {
buf := &bytes.Buffer{}
if err := format.WriteJSONFiltered(buf, nil, []string{"id"}, ""); err != nil {
t.Fatalf("err = %v", err)
}
if strings.TrimSpace(buf.String()) != "null" {
t.Errorf("nil should marshal to bare null, got %q", buf.String())
}
}
+3 -8
View File
@@ -11,9 +11,9 @@ import (
// Envelope is the canonical success/failure shape returned by every command.
//
// v0.2 ADR-3 added Notice / Risk / DryRun. All three are absent on
// read-only commands; write commands populate Risk even on success so
// agents can record what action ran.
// v0.2 ADR-3 added Notice / Risk. Both are absent on read-only commands;
// write commands populate Risk even on success so agents can record what
// action ran.
type Envelope struct {
OK bool `json:"ok"`
Data any `json:"data,omitempty"`
@@ -21,11 +21,6 @@ type Envelope struct {
Meta *Meta `json:"_meta,omitempty"`
Notice *Notice `json:"_notice,omitempty"`
Risk *Risk `json:"risk,omitempty"`
// DryRun is intentionally NOT omitempty: AGENTS.md documents
// `dry_run: false` as the literal default in the schema example, so
// agents that pin the field's presence aren't surprised by it
// disappearing on non-dry-run envelopes.
DryRun bool `json:"dry_run"`
}
// Notice carries system-level advisories independent of the command outcome:
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// - len(fields) == 0 → no field filter (full envelope)
// - jqExpr == "" → no jq filter (just write the envelope)
//
// The envelope structure (ok / data / error / _meta / risk / dry_run / _notice)
// The envelope structure (ok / data / error / _meta / risk / _notice)
// is preserved across field filtering — only Data is rewritten. jq operates
// on the entire envelope JSON so users can `--jq '.data.items[].id'`.
func WriteEnvelopeFiltered(w io.Writer, env Envelope, fields []string, jqExpr string) error {
+2 -3
View File
@@ -109,7 +109,7 @@ func TestWriteEnvelopeFiltered_UnknownFieldSilent(t *testing.T) {
}
func TestWriteEnvelopeFiltered_PreservesEnvelopeFields(t *testing.T) {
// Even with field filter, meta/risk/dry_run/error must be preserved.
// Even with field filter, meta/risk/error must be preserved.
env := format.Success(map[string]any{"items": []any{
map[string]any{"id": "1", "name": "x", "kb_id": "kb"},
}}, &format.Meta{KBID: "kb_x", RequestID: "req_123"})
@@ -119,12 +119,11 @@ func TestWriteEnvelopeFiltered_PreservesEnvelopeFields(t *testing.T) {
t.Fatalf("err = %v", err)
}
var got struct {
OK bool `json:"ok"`
OK bool `json:"ok"`
Meta *format.Meta `json:"_meta"`
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
DryRun bool `json:"dry_run"`
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("parse: %v\n%s", err, buf.String())